Skip to main content

fresh/
config.rs

1use crate::types::{context_keys, LspLanguageConfig, LspServerConfig, ProcessLimits};
2
3use rust_i18n::t;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::borrow::Cow;
7use std::collections::HashMap;
8use std::ops::Deref;
9use std::path::Path;
10
11/// Newtype for theme name that generates proper JSON Schema with enum options
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct ThemeName(pub String);
15
16impl ThemeName {
17    /// Built-in theme options shown in the settings dropdown
18    pub const BUILTIN_OPTIONS: &'static [&'static str] =
19        &["dark", "light", "high-contrast", "nostalgia"];
20}
21
22impl Deref for ThemeName {
23    type Target = str;
24    fn deref(&self) -> &Self::Target {
25        &self.0
26    }
27}
28
29impl From<String> for ThemeName {
30    fn from(s: String) -> Self {
31        Self(s)
32    }
33}
34
35impl From<&str> for ThemeName {
36    fn from(s: &str) -> Self {
37        Self(s.to_string())
38    }
39}
40
41impl PartialEq<str> for ThemeName {
42    fn eq(&self, other: &str) -> bool {
43        self.0 == other
44    }
45}
46
47impl PartialEq<ThemeName> for str {
48    fn eq(&self, other: &ThemeName) -> bool {
49        self == other.0
50    }
51}
52
53impl JsonSchema for ThemeName {
54    fn schema_name() -> Cow<'static, str> {
55        Cow::Borrowed("ThemeOptions")
56    }
57
58    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
59        schemars::json_schema!({
60            "description": "Available color themes",
61            "type": "string",
62            "enum": Self::BUILTIN_OPTIONS
63        })
64    }
65}
66
67/// Newtype for locale name that generates proper JSON Schema with enum options
68/// Wraps Option<String> to allow null for auto-detection from environment
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
70#[serde(transparent)]
71pub struct LocaleName(pub Option<String>);
72
73// Include the generated locale options from build.rs
74include!(concat!(env!("OUT_DIR"), "/locale_options.rs"));
75
76impl LocaleName {
77    /// Available locale options shown in the settings dropdown
78    /// null means auto-detect from environment
79    /// This is auto-generated from the locales/*.json files by build.rs
80    pub const LOCALE_OPTIONS: &'static [Option<&'static str>] = GENERATED_LOCALE_OPTIONS;
81
82    /// Get the inner value as Option<&str>
83    pub fn as_option(&self) -> Option<&str> {
84        self.0.as_deref()
85    }
86}
87
88impl From<Option<String>> for LocaleName {
89    fn from(s: Option<String>) -> Self {
90        Self(s)
91    }
92}
93
94impl From<Option<&str>> for LocaleName {
95    fn from(s: Option<&str>) -> Self {
96        Self(s.map(|s| s.to_string()))
97    }
98}
99
100impl JsonSchema for LocaleName {
101    fn schema_name() -> Cow<'static, str> {
102        Cow::Borrowed("LocaleOptions")
103    }
104
105    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
106        schemars::json_schema!({
107            "description": "UI locale (language). Use null for auto-detection from environment.",
108            "enum": Self::LOCALE_OPTIONS
109        })
110    }
111}
112
113/// Cursor style options for the terminal cursor
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "snake_case")]
116pub enum CursorStyle {
117    /// Use the terminal's default cursor style
118    #[default]
119    Default,
120    /// Blinking block cursor (█)
121    BlinkingBlock,
122    /// Solid block cursor (█)
123    SteadyBlock,
124    /// Blinking vertical bar cursor (│)
125    BlinkingBar,
126    /// Solid vertical bar cursor (│)
127    SteadyBar,
128    /// Blinking underline cursor (_)
129    BlinkingUnderline,
130    /// Solid underline cursor (_)
131    SteadyUnderline,
132}
133
134impl CursorStyle {
135    /// All available cursor style options
136    pub const OPTIONS: &'static [&'static str] = &[
137        "default",
138        "blinking_block",
139        "steady_block",
140        "blinking_bar",
141        "steady_bar",
142        "blinking_underline",
143        "steady_underline",
144    ];
145
146    /// Human-readable descriptions for each cursor style
147    pub const DESCRIPTIONS: &'static [&'static str] = &[
148        "Terminal default",
149        "█ Blinking block",
150        "█ Solid block",
151        "│ Blinking bar",
152        "│ Solid bar",
153        "_ Blinking underline",
154        "_ Solid underline",
155    ];
156
157    /// Convert to crossterm cursor style (runtime only)
158    #[cfg(feature = "runtime")]
159    pub fn to_crossterm_style(self) -> crossterm::cursor::SetCursorStyle {
160        use crossterm::cursor::SetCursorStyle;
161        match self {
162            Self::Default => SetCursorStyle::DefaultUserShape,
163            Self::BlinkingBlock => SetCursorStyle::BlinkingBlock,
164            Self::SteadyBlock => SetCursorStyle::SteadyBlock,
165            Self::BlinkingBar => SetCursorStyle::BlinkingBar,
166            Self::SteadyBar => SetCursorStyle::SteadyBar,
167            Self::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore,
168            Self::SteadyUnderline => SetCursorStyle::SteadyUnderScore,
169        }
170    }
171
172    /// Get the ANSI escape sequence for this cursor style (DECSCUSR)
173    /// Used for session mode where we can't write directly to terminal
174    pub fn to_escape_sequence(self) -> &'static [u8] {
175        match self {
176            Self::Default => b"\x1b[0 q",
177            Self::BlinkingBlock => b"\x1b[1 q",
178            Self::SteadyBlock => b"\x1b[2 q",
179            Self::BlinkingUnderline => b"\x1b[3 q",
180            Self::SteadyUnderline => b"\x1b[4 q",
181            Self::BlinkingBar => b"\x1b[5 q",
182            Self::SteadyBar => b"\x1b[6 q",
183        }
184    }
185
186    /// Parse from string (for command palette)
187    pub fn parse(s: &str) -> Option<Self> {
188        match s {
189            "default" => Some(CursorStyle::Default),
190            "blinking_block" => Some(CursorStyle::BlinkingBlock),
191            "steady_block" => Some(CursorStyle::SteadyBlock),
192            "blinking_bar" => Some(CursorStyle::BlinkingBar),
193            "steady_bar" => Some(CursorStyle::SteadyBar),
194            "blinking_underline" => Some(CursorStyle::BlinkingUnderline),
195            "steady_underline" => Some(CursorStyle::SteadyUnderline),
196            _ => None,
197        }
198    }
199
200    /// Convert to string representation
201    pub fn as_str(self) -> &'static str {
202        match self {
203            Self::Default => "default",
204            Self::BlinkingBlock => "blinking_block",
205            Self::SteadyBlock => "steady_block",
206            Self::BlinkingBar => "blinking_bar",
207            Self::SteadyBar => "steady_bar",
208            Self::BlinkingUnderline => "blinking_underline",
209            Self::SteadyUnderline => "steady_underline",
210        }
211    }
212}
213
214impl JsonSchema for CursorStyle {
215    fn schema_name() -> Cow<'static, str> {
216        Cow::Borrowed("CursorStyle")
217    }
218
219    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
220        schemars::json_schema!({
221            "description": "Terminal cursor style",
222            "type": "string",
223            "enum": Self::OPTIONS
224        })
225    }
226}
227
228/// Newtype for keybinding map name that generates proper JSON Schema with enum options
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(transparent)]
231pub struct KeybindingMapName(pub String);
232
233impl KeybindingMapName {
234    /// Built-in keybinding map options shown in the settings dropdown
235    pub const BUILTIN_OPTIONS: &'static [&'static str] =
236        &["default", "emacs", "vscode", "macos", "macos-gui"];
237}
238
239impl Deref for KeybindingMapName {
240    type Target = str;
241    fn deref(&self) -> &Self::Target {
242        &self.0
243    }
244}
245
246impl From<String> for KeybindingMapName {
247    fn from(s: String) -> Self {
248        Self(s)
249    }
250}
251
252impl From<&str> for KeybindingMapName {
253    fn from(s: &str) -> Self {
254        Self(s.to_string())
255    }
256}
257
258impl PartialEq<str> for KeybindingMapName {
259    fn eq(&self, other: &str) -> bool {
260        self.0 == other
261    }
262}
263
264/// Line ending format for new files
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum LineEndingOption {
268    /// Unix/Linux/macOS format (LF)
269    #[default]
270    Lf,
271    /// Windows format (CRLF)
272    Crlf,
273    /// Classic Mac format (CR) - rare
274    Cr,
275}
276
277impl LineEndingOption {
278    /// Convert to the buffer's LineEnding type
279    pub fn to_line_ending(&self) -> crate::model::buffer::LineEnding {
280        match self {
281            Self::Lf => crate::model::buffer::LineEnding::LF,
282            Self::Crlf => crate::model::buffer::LineEnding::CRLF,
283            Self::Cr => crate::model::buffer::LineEnding::CR,
284        }
285    }
286}
287
288impl JsonSchema for LineEndingOption {
289    fn schema_name() -> Cow<'static, str> {
290        Cow::Borrowed("LineEndingOption")
291    }
292
293    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
294        schemars::json_schema!({
295            "description": "Default line ending format for new files",
296            "type": "string",
297            "enum": ["lf", "crlf", "cr"],
298            "default": "lf"
299        })
300    }
301}
302
303impl PartialEq<KeybindingMapName> for str {
304    fn eq(&self, other: &KeybindingMapName) -> bool {
305        self == other.0
306    }
307}
308
309impl JsonSchema for KeybindingMapName {
310    fn schema_name() -> Cow<'static, str> {
311        Cow::Borrowed("KeybindingMapOptions")
312    }
313
314    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
315        schemars::json_schema!({
316            "description": "Available keybinding maps",
317            "type": "string",
318            "enum": Self::BUILTIN_OPTIONS
319        })
320    }
321}
322
323/// Main configuration structure
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct Config {
326    /// Configuration version (for migration support)
327    /// Configs without this field are treated as version 0
328    #[serde(default)]
329    pub version: u32,
330
331    /// Color theme name
332    #[serde(default = "default_theme_name")]
333    pub theme: ThemeName,
334
335    /// UI locale (language) for translations
336    /// If not set, auto-detected from environment (LC_ALL, LC_MESSAGES, LANG)
337    #[serde(default)]
338    pub locale: LocaleName,
339
340    /// Check for new versions on startup (default: true).
341    /// When enabled, also sends basic anonymous telemetry (version, OS, terminal type).
342    #[serde(default = "default_true")]
343    pub check_for_updates: bool,
344
345    /// Editor behavior settings (indentation, line numbers, wrapping, etc.)
346    #[serde(default)]
347    pub editor: EditorConfig,
348
349    /// File explorer panel settings
350    #[serde(default)]
351    pub file_explorer: FileExplorerConfig,
352
353    /// File browser settings (Open File dialog)
354    #[serde(default)]
355    pub file_browser: FileBrowserConfig,
356
357    /// Clipboard settings (which clipboard methods to use)
358    #[serde(default)]
359    pub clipboard: ClipboardConfig,
360
361    /// Terminal settings
362    #[serde(default)]
363    pub terminal: TerminalConfig,
364
365    /// Custom keybindings (overrides for the active map)
366    #[serde(default)]
367    pub keybindings: Vec<Keybinding>,
368
369    /// Named keybinding maps (user can define custom maps here)
370    /// Each map can optionally inherit from another map
371    #[serde(default)]
372    pub keybinding_maps: HashMap<String, KeymapConfig>,
373
374    /// Active keybinding map name
375    #[serde(default = "default_keybinding_map_name")]
376    pub active_keybinding_map: KeybindingMapName,
377
378    /// Per-language configuration overrides (tab size, formatters, etc.)
379    #[serde(default)]
380    pub languages: HashMap<String, LanguageConfig>,
381
382    /// Default language for files whose type cannot be detected.
383    /// Must reference a key in the `languages` map (e.g., "bash").
384    /// Applied when no extension, filename, glob, or built-in detection matches.
385    /// The referenced language's full configuration (grammar, comment_prefix,
386    /// tab_size, etc.) is used for unrecognized files.
387    #[serde(default)]
388    #[schemars(extend("x-enum-from" = "/languages"))]
389    pub default_language: Option<String>,
390
391    /// LSP server configurations by language.
392    /// Each language maps to one or more server configs (multi-LSP support).
393    /// Accepts both single-object and array forms for backwards compatibility.
394    #[serde(default)]
395    pub lsp: HashMap<String, LspLanguageConfig>,
396
397    /// Universal LSP servers that apply to all languages.
398    /// These servers run alongside language-specific LSP servers defined in `lsp`.
399    /// Keyed by a unique server name (e.g. "quicklsp").
400    #[serde(default)]
401    pub universal_lsp: HashMap<String, LspLanguageConfig>,
402
403    /// Warning notification settings
404    #[serde(default)]
405    pub warnings: WarningsConfig,
406
407    /// Plugin configurations by plugin name
408    /// Plugins are auto-discovered from the plugins directory.
409    /// Use this to enable/disable specific plugins.
410    #[serde(default)]
411    #[schemars(extend("x-standalone-category" = true, "x-no-add" = true))]
412    pub plugins: HashMap<String, PluginConfig>,
413
414    /// Package manager settings for plugin/theme installation
415    #[serde(default)]
416    pub packages: PackagesConfig,
417}
418
419fn default_keybinding_map_name() -> KeybindingMapName {
420    // On macOS, default to the macOS keymap which has Mac-specific bindings
421    // (Ctrl+A/E for Home/End, Ctrl+Shift+Z for redo, etc.)
422    if cfg!(target_os = "macos") {
423        KeybindingMapName("macos".to_string())
424    } else {
425        KeybindingMapName("default".to_string())
426    }
427}
428
429fn default_theme_name() -> ThemeName {
430    ThemeName("high-contrast".to_string())
431}
432
433/// Resolved whitespace indicator visibility for a buffer.
434///
435/// These are the final resolved flags after applying master toggle,
436/// global config, and per-language overrides. Used directly by the renderer.
437#[derive(Debug, Clone, Copy)]
438pub struct WhitespaceVisibility {
439    pub spaces_leading: bool,
440    pub spaces_inner: bool,
441    pub spaces_trailing: bool,
442    pub tabs_leading: bool,
443    pub tabs_inner: bool,
444    pub tabs_trailing: bool,
445}
446
447impl Default for WhitespaceVisibility {
448    fn default() -> Self {
449        // Match EditorConfig defaults: tabs all on, spaces all off
450        Self {
451            spaces_leading: false,
452            spaces_inner: false,
453            spaces_trailing: false,
454            tabs_leading: true,
455            tabs_inner: true,
456            tabs_trailing: true,
457        }
458    }
459}
460
461impl WhitespaceVisibility {
462    /// Resolve from EditorConfig flat fields (applying master toggle)
463    pub fn from_editor_config(editor: &EditorConfig) -> Self {
464        if !editor.whitespace_show {
465            return Self {
466                spaces_leading: false,
467                spaces_inner: false,
468                spaces_trailing: false,
469                tabs_leading: false,
470                tabs_inner: false,
471                tabs_trailing: false,
472            };
473        }
474        Self {
475            spaces_leading: editor.whitespace_spaces_leading,
476            spaces_inner: editor.whitespace_spaces_inner,
477            spaces_trailing: editor.whitespace_spaces_trailing,
478            tabs_leading: editor.whitespace_tabs_leading,
479            tabs_inner: editor.whitespace_tabs_inner,
480            tabs_trailing: editor.whitespace_tabs_trailing,
481        }
482    }
483
484    /// Apply a language-level override for tab visibility.
485    /// When the language sets `show_whitespace_tabs: false`, all tab positions are disabled.
486    pub fn with_language_tab_override(mut self, show_whitespace_tabs: bool) -> Self {
487        if !show_whitespace_tabs {
488            self.tabs_leading = false;
489            self.tabs_inner = false;
490            self.tabs_trailing = false;
491        }
492        self
493    }
494
495    /// Returns true if any space indicator is enabled
496    pub fn any_spaces(&self) -> bool {
497        self.spaces_leading || self.spaces_inner || self.spaces_trailing
498    }
499
500    /// Returns true if any tab indicator is enabled
501    pub fn any_tabs(&self) -> bool {
502        self.tabs_leading || self.tabs_inner || self.tabs_trailing
503    }
504
505    /// Returns true if any indicator (space or tab) is enabled
506    pub fn any_visible(&self) -> bool {
507        self.any_spaces() || self.any_tabs()
508    }
509
510    /// Toggle all whitespace indicators on/off (master switch).
511    /// When turning off, all positions are disabled.
512    /// When turning on, restores to default visibility (tabs all on, spaces all off).
513    pub fn toggle_all(&mut self) {
514        if self.any_visible() {
515            *self = Self {
516                spaces_leading: false,
517                spaces_inner: false,
518                spaces_trailing: false,
519                tabs_leading: false,
520                tabs_inner: false,
521                tabs_trailing: false,
522            };
523        } else {
524            *self = Self::default();
525        }
526    }
527}
528
529/// A status bar element that can be placed in the left or right container.
530///
531/// Elements are specified as strings in the config:
532/// - `"{filename}"` — file path with session/remote prefix, modified and read-only indicators
533/// - `"{cursor}"` — cursor position as `Ln 1, Col 1`
534/// - `"{cursor:compact}"` — cursor position as `1:1`
535/// - `"{diagnostics}"` — error/warning/info counts (e.g. `E:1 W:2`)
536/// - `"{cursor_count}"` — number of active cursors (hidden when only 1)
537/// - `"{messages}"` — editor and plugin status messages
538/// - `"{chord}"` — in-progress chord key sequence
539/// - `"{line_ending}"` — line ending format (LF, CRLF, Auto)
540/// - `"{encoding}"` — file encoding (e.g. UTF-8)
541/// - `"{language}"` — detected language name
542/// - `"{lsp}"` — LSP server status indicator
543/// - `"{warnings}"` — general warning badge
544/// - `"{update}"` — update available indicator
545/// - `"{palette}"` — command palette shortcut hint
546/// - `"{clock}"` — current time (HH:MM) with blinking colon separator
547/// - `"{remote}"` — remote authority indicator (Local / SSH / Container / Disconnected)
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(try_from = "String", into = "String")]
550pub enum StatusBarElement {
551    /// File path with session/remote prefix, modified/read-only indicators
552    Filename,
553    /// Cursor position (default format: `Ln 1, Col 1`)
554    Cursor,
555    /// Cursor position (compact format: `1:1`)
556    CursorCompact,
557    /// Diagnostic counts (errors, warnings, info)
558    Diagnostics,
559    /// Active cursor count (hidden when 1)
560    CursorCount,
561    /// Status messages from editor and plugins
562    Messages,
563    /// In-progress chord key sequence
564    Chord,
565    /// Line ending indicator (LF/CRLF/Auto)
566    LineEnding,
567    /// File encoding (e.g. UTF-8)
568    Encoding,
569    /// Detected language name
570    Language,
571    /// LSP server status
572    Lsp,
573    /// General warning badge
574    Warnings,
575    /// Update available indicator
576    Update,
577    /// Command palette shortcut hint
578    Palette,
579    /// Current time (HH:MM) with blinking colon separator
580    Clock,
581    /// Remote authority indicator: shows "Local", the active SSH/Container
582    /// authority label, or a disconnected marker. Intended for placement at
583    /// the bottom-left of the status bar as a persistent remote-state entry
584    /// point.
585    RemoteIndicator,
586}
587
588impl TryFrom<String> for StatusBarElement {
589    type Error = String;
590    fn try_from(s: String) -> Result<Self, String> {
591        // Strip surrounding braces if present: "{foo}" -> "foo"
592        let inner = s
593            .strip_prefix('{')
594            .and_then(|s| s.strip_suffix('}'))
595            .unwrap_or(&s);
596        match inner {
597            "filename" => Ok(Self::Filename),
598            "cursor" => Ok(Self::Cursor),
599            "cursor:compact" => Ok(Self::CursorCompact),
600            "diagnostics" => Ok(Self::Diagnostics),
601            "cursor_count" => Ok(Self::CursorCount),
602            "messages" => Ok(Self::Messages),
603            "chord" => Ok(Self::Chord),
604            "line_ending" => Ok(Self::LineEnding),
605            "encoding" => Ok(Self::Encoding),
606            "language" => Ok(Self::Language),
607            "lsp" => Ok(Self::Lsp),
608            "warnings" => Ok(Self::Warnings),
609            "update" => Ok(Self::Update),
610            "palette" => Ok(Self::Palette),
611            "clock" => Ok(Self::Clock),
612            "remote" => Ok(Self::RemoteIndicator),
613            _ => Err(format!("Unknown status bar element: {}", s)),
614        }
615    }
616}
617
618impl From<StatusBarElement> for String {
619    fn from(e: StatusBarElement) -> String {
620        match e {
621            StatusBarElement::Filename => "{filename}".to_string(),
622            StatusBarElement::Cursor => "{cursor}".to_string(),
623            StatusBarElement::CursorCompact => "{cursor:compact}".to_string(),
624            StatusBarElement::Diagnostics => "{diagnostics}".to_string(),
625            StatusBarElement::CursorCount => "{cursor_count}".to_string(),
626            StatusBarElement::Messages => "{messages}".to_string(),
627            StatusBarElement::Chord => "{chord}".to_string(),
628            StatusBarElement::LineEnding => "{line_ending}".to_string(),
629            StatusBarElement::Encoding => "{encoding}".to_string(),
630            StatusBarElement::Language => "{language}".to_string(),
631            StatusBarElement::Lsp => "{lsp}".to_string(),
632            StatusBarElement::Warnings => "{warnings}".to_string(),
633            StatusBarElement::Update => "{update}".to_string(),
634            StatusBarElement::Palette => "{palette}".to_string(),
635            StatusBarElement::Clock => "{clock}".to_string(),
636            StatusBarElement::RemoteIndicator => "{remote}".to_string(),
637        }
638    }
639}
640
641impl schemars::JsonSchema for StatusBarElement {
642    fn schema_name() -> std::borrow::Cow<'static, str> {
643        std::borrow::Cow::Borrowed("StatusBarElement")
644    }
645    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
646        schemars::json_schema!({
647            "type": "string",
648            "x-dual-list-options": [
649                {"value": "{filename}", "name": "Filename"},
650                {"value": "{cursor}", "name": "Cursor"},
651                {"value": "{cursor:compact}", "name": "Cursor (compact)"},
652                {"value": "{diagnostics}", "name": "Diagnostics"},
653                {"value": "{cursor_count}", "name": "Cursor Count"},
654                {"value": "{messages}", "name": "Messages"},
655                {"value": "{chord}", "name": "Chord"},
656                {"value": "{line_ending}", "name": "Line Ending"},
657                {"value": "{encoding}", "name": "Encoding"},
658                {"value": "{language}", "name": "Language"},
659                {"value": "{lsp}", "name": "LSP"},
660                {"value": "{warnings}", "name": "Warnings"},
661                {"value": "{update}", "name": "Update"},
662                {"value": "{palette}", "name": "Palette"},
663                {"value": "{clock}", "name": "Clock"},
664                {"value": "{remote}", "name": "Remote Indicator"}
665            ]
666        })
667    }
668}
669
670fn default_status_bar_left() -> Vec<StatusBarElement> {
671    // `{remote}` leads so the clickable Remote Indicator is the
672    // first thing on the bottom-left, matching the spec's
673    // "persistent control" requirement and where users learn to
674    // look for it from VS Code. Mouse-clickable, F6-bindable.
675    //
676    // Note: the `Filename` element historically also prepended
677    // `[Container:<id>] ` / `<SSH_PREFIX>conn<TERMINATOR>` to its
678    // text. That's redundant with this indicator — see the
679    // matching change in `view::ui::status_bar::render_element`'s
680    // Filename branch, which now skips the prefix when the
681    // indicator is on the bar.
682    vec![
683        StatusBarElement::RemoteIndicator,
684        StatusBarElement::Filename,
685        StatusBarElement::Cursor,
686        StatusBarElement::Diagnostics,
687        StatusBarElement::CursorCount,
688        StatusBarElement::Messages,
689    ]
690}
691
692fn default_status_bar_right() -> Vec<StatusBarElement> {
693    vec![
694        StatusBarElement::LineEnding,
695        StatusBarElement::Encoding,
696        StatusBarElement::Language,
697        StatusBarElement::Lsp,
698        StatusBarElement::Warnings,
699        StatusBarElement::Update,
700        StatusBarElement::Palette,
701    ]
702}
703
704/// Status bar layout and element configuration.
705///
706/// Controls which elements appear in the status bar and how they are arranged.
707/// Elements are placed in left and right containers and can be freely reordered.
708///
709/// Example config:
710/// ```json
711/// {
712///   "status_bar": {
713///     "left": ["{filename}", "{cursor:compact}"],
714///     "right": ["{language}", "{encoding}", "{line_ending}"]
715///   }
716/// }
717/// ```
718#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
719pub struct StatusBarConfig {
720    /// Elements shown on the left side of the status bar.
721    /// Default: ["{filename}", "{cursor}", "{diagnostics}", "{cursor_count}", "{messages}"]
722    #[serde(default = "default_status_bar_left")]
723    #[schemars(extend("x-section" = "Status Bar", "x-dual-list-sibling" = "/editor/status_bar/right"))]
724    pub left: Vec<StatusBarElement>,
725
726    /// Elements shown on the right side of the status bar.
727    /// Default: ["{line_ending}", "{encoding}", "{language}", "{lsp}", "{warnings}", "{update}", "{palette}"]
728    #[serde(default = "default_status_bar_right")]
729    #[schemars(extend("x-section" = "Status Bar", "x-dual-list-sibling" = "/editor/status_bar/left"))]
730    pub right: Vec<StatusBarElement>,
731}
732
733impl Default for StatusBarConfig {
734    fn default() -> Self {
735        Self {
736            left: default_status_bar_left(),
737            right: default_status_bar_right(),
738        }
739    }
740}
741
742/// Editor behavior configuration
743#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
744pub struct EditorConfig {
745    // ===== Display =====
746    /// Enable frame-buffer animations (tab-switch slides, dashboard
747    /// bringup, plugin-driven effects). When `false`, every animation
748    /// call is a no-op: the UI is fully static and each render lands
749    /// the final frame immediately. Useful on slow terminals, over
750    /// SSH, or for users who prefer no motion.
751    #[serde(default = "default_true")]
752    #[schemars(extend("x-section" = "Display"))]
753    pub animations: bool,
754
755    /// Show line numbers in the gutter (default for new buffers)
756    #[serde(default = "default_true")]
757    #[schemars(extend("x-section" = "Display"))]
758    pub line_numbers: bool,
759
760    /// Show line numbers relative to cursor position
761    #[serde(default = "default_false")]
762    #[schemars(extend("x-section" = "Display"))]
763    pub relative_line_numbers: bool,
764
765    /// Highlight the line containing the cursor
766    #[serde(default = "default_true")]
767    #[schemars(extend("x-section" = "Display"))]
768    pub highlight_current_line: bool,
769
770    /// Highlight the column containing the cursor
771    #[serde(default = "default_false")]
772    #[schemars(extend("x-section" = "Display"))]
773    pub highlight_current_column: bool,
774
775    /// Wrap long lines to fit the window width (default for new views)
776    #[serde(default = "default_true")]
777    #[schemars(extend("x-section" = "Display"))]
778    pub line_wrap: bool,
779
780    /// Indent wrapped continuation lines to match the leading whitespace of the original line
781    #[serde(default = "default_true")]
782    #[schemars(extend("x-section" = "Display"))]
783    pub wrap_indent: bool,
784
785    /// Column at which to wrap lines when line wrapping is enabled.
786    /// If not specified (`null`), lines wrap at the viewport edge (default behavior).
787    /// Example: `80` wraps at column 80. The actual wrap column is clamped to the
788    /// viewport width (lines can't wrap beyond the visible area).
789    #[serde(default)]
790    #[schemars(extend("x-section" = "Display"))]
791    pub wrap_column: Option<usize>,
792
793    /// Width of the page in page view mode (in columns).
794    /// Controls the content width when page view is active, with centering margins.
795    /// Defaults to 80. Set to `null` to use the full viewport width.
796    #[serde(default = "default_page_width")]
797    #[schemars(extend("x-section" = "Display"))]
798    pub page_width: Option<usize>,
799
800    /// Enable syntax highlighting for code files
801    #[serde(default = "default_true")]
802    #[schemars(extend("x-section" = "Display"))]
803    pub syntax_highlighting: bool,
804
805    /// Whether the menu bar is visible by default.
806    /// The menu bar provides access to menus (File, Edit, View, etc.) at the top of the screen.
807    /// Can be toggled at runtime via command palette or keybinding.
808    /// Default: true
809    #[serde(default = "default_true")]
810    #[schemars(extend("x-section" = "Display"))]
811    pub show_menu_bar: bool,
812
813    /// Whether menu bar mnemonics (Alt+letter shortcuts) are enabled.
814    /// When enabled, pressing Alt+F opens the File menu, Alt+E opens Edit, etc.
815    /// Disabling this frees up Alt+letter keybindings for other actions.
816    /// Default: true
817    #[serde(default = "default_true")]
818    #[schemars(extend("x-section" = "Display"))]
819    pub menu_bar_mnemonics: bool,
820
821    /// Whether the tab bar is visible by default.
822    /// The tab bar shows open files in each split pane.
823    /// Can be toggled at runtime via command palette or keybinding.
824    /// Default: true
825    #[serde(default = "default_true")]
826    #[schemars(extend("x-section" = "Display"))]
827    pub show_tab_bar: bool,
828
829    /// Whether the status bar is visible by default.
830    /// The status bar shows file info, cursor position, and editor status at the bottom of the screen.
831    /// Can be toggled at runtime via command palette or keybinding.
832    /// Default: true
833    #[serde(default = "default_true")]
834    #[schemars(extend("x-section" = "Display"))]
835    pub show_status_bar: bool,
836
837    /// Status bar layout and element configuration.
838    /// Controls which elements appear in the status bar and how they are arranged.
839    #[serde(default)]
840    #[schemars(extend("x-section" = "Status Bar"))]
841    pub status_bar: StatusBarConfig,
842
843    /// Whether the prompt line is visible by default.
844    /// The prompt line is the bottom-most line used for command input, search, file open, etc.
845    /// When hidden, the prompt line only appears when a prompt is active.
846    /// Can be toggled at runtime via command palette or keybinding.
847    /// Default: true
848    #[serde(default = "default_true")]
849    #[schemars(extend("x-section" = "Display"))]
850    pub show_prompt_line: bool,
851
852    /// Whether the vertical scrollbar is visible in each split pane.
853    /// Can be toggled at runtime via command palette or keybinding.
854    /// Default: true
855    #[serde(default = "default_true")]
856    #[schemars(extend("x-section" = "Display"))]
857    pub show_vertical_scrollbar: bool,
858
859    /// Whether the horizontal scrollbar is visible in each split pane.
860    /// The horizontal scrollbar appears when line wrap is disabled and content extends beyond the viewport.
861    /// Can be toggled at runtime via command palette or keybinding.
862    /// Default: false
863    #[serde(default = "default_false")]
864    #[schemars(extend("x-section" = "Display"))]
865    pub show_horizontal_scrollbar: bool,
866
867    /// Show tilde (~) markers on lines after the end of the file.
868    /// These vim-style markers indicate lines that are not part of the file content.
869    /// Default: true
870    #[serde(default = "default_true")]
871    #[schemars(extend("x-section" = "Display"))]
872    pub show_tilde: bool,
873
874    /// Use the terminal's default background color instead of the theme's editor background.
875    /// When enabled, the editor background inherits from the terminal emulator,
876    /// allowing transparency or custom terminal backgrounds to show through.
877    /// Default: false
878    #[serde(default = "default_false")]
879    #[schemars(extend("x-section" = "Display"))]
880    pub use_terminal_bg: bool,
881
882    /// Update the terminal window title (via OSC 2) to reflect the active buffer.
883    /// When enabled, Fresh sets the terminal/tab title to "<file> — Fresh" as
884    /// you switch buffers. Harmless on terminals that don't understand the
885    /// escape sequence — they silently ignore it.
886    /// Default: true
887    #[serde(default = "default_true")]
888    #[schemars(extend("x-section" = "Display"))]
889    pub set_window_title: bool,
890
891    /// Cursor style for the terminal cursor.
892    /// Options: blinking_block, steady_block, blinking_bar, steady_bar, blinking_underline, steady_underline
893    /// Default: blinking_block
894    #[serde(default)]
895    #[schemars(extend("x-section" = "Display"))]
896    pub cursor_style: CursorStyle,
897
898    /// Vertical ruler lines at specific column positions.
899    /// Draws subtle vertical lines to help with line length conventions.
900    /// Example: [80, 120] draws rulers at columns 80 and 120.
901    /// Default: [] (no rulers)
902    #[serde(default)]
903    #[schemars(extend("x-section" = "Display"))]
904    pub rulers: Vec<usize>,
905
906    // ===== Whitespace =====
907    /// Master toggle for whitespace indicator visibility.
908    /// When disabled, no whitespace indicators (·, →) are shown regardless
909    /// of the per-position settings below.
910    /// Default: true
911    #[serde(default = "default_true")]
912    #[schemars(extend("x-section" = "Whitespace"))]
913    pub whitespace_show: bool,
914
915    /// Show space indicators (·) for leading whitespace (indentation).
916    /// Leading whitespace is everything before the first non-space character on a line.
917    /// Default: false
918    #[serde(default = "default_false")]
919    #[schemars(extend("x-section" = "Whitespace"))]
920    pub whitespace_spaces_leading: bool,
921
922    /// Show space indicators (·) for inner whitespace (between words/tokens).
923    /// Inner whitespace is spaces between the first and last non-space characters.
924    /// Default: false
925    #[serde(default = "default_false")]
926    #[schemars(extend("x-section" = "Whitespace"))]
927    pub whitespace_spaces_inner: bool,
928
929    /// Show space indicators (·) for trailing whitespace.
930    /// Trailing whitespace is everything after the last non-space character on a line.
931    /// Default: false
932    #[serde(default = "default_false")]
933    #[schemars(extend("x-section" = "Whitespace"))]
934    pub whitespace_spaces_trailing: bool,
935
936    /// Show tab indicators (→) for leading tabs (indentation).
937    /// Can be overridden per-language via `show_whitespace_tabs` in language config.
938    /// Default: true
939    #[serde(default = "default_true")]
940    #[schemars(extend("x-section" = "Whitespace"))]
941    pub whitespace_tabs_leading: bool,
942
943    /// Show tab indicators (→) for inner tabs (between words/tokens).
944    /// Can be overridden per-language via `show_whitespace_tabs` in language config.
945    /// Default: true
946    #[serde(default = "default_true")]
947    #[schemars(extend("x-section" = "Whitespace"))]
948    pub whitespace_tabs_inner: bool,
949
950    /// Show tab indicators (→) for trailing tabs.
951    /// Can be overridden per-language via `show_whitespace_tabs` in language config.
952    /// Default: true
953    #[serde(default = "default_true")]
954    #[schemars(extend("x-section" = "Whitespace"))]
955    pub whitespace_tabs_trailing: bool,
956
957    // ===== Editing =====
958    /// Whether pressing Tab inserts a tab character instead of spaces.
959    /// This is the global default; individual languages can override it
960    /// via their own `use_tabs` setting.
961    /// Default: false (insert spaces)
962    #[serde(default = "default_false")]
963    #[schemars(extend("x-section" = "Editing"))]
964    pub use_tabs: bool,
965
966    /// Number of spaces per tab character
967    #[serde(default = "default_tab_size")]
968    #[schemars(extend("x-section" = "Editing"))]
969    pub tab_size: usize,
970
971    /// Automatically indent new lines based on the previous line
972    #[serde(default = "default_true")]
973    #[schemars(extend("x-section" = "Editing"))]
974    pub auto_indent: bool,
975
976    /// Automatically close brackets, parentheses, and quotes when typing.
977    /// When enabled, typing an opening delimiter like `(`, `[`, `{`, `"`, `'`, or `` ` ``
978    /// will automatically insert the matching closing delimiter.
979    /// Also enables skip-over (moving past existing closing delimiters) and
980    /// pair deletion (deleting both delimiters when backspacing between them).
981    /// Default: true
982    #[serde(default = "default_true")]
983    #[schemars(extend("x-section" = "Editing"))]
984    pub auto_close: bool,
985
986    /// Automatically surround selected text with matching pairs when typing
987    /// an opening delimiter. When enabled and text is selected, typing `(`, `[`,
988    /// `{`, `"`, `'`, or `` ` `` wraps the selection instead of replacing it.
989    /// Default: true
990    #[serde(default = "default_true")]
991    #[schemars(extend("x-section" = "Editing"))]
992    pub auto_surround: bool,
993
994    /// Minimum lines to keep visible above/below cursor when scrolling
995    #[serde(default = "default_scroll_offset")]
996    #[schemars(extend("x-section" = "Editing"))]
997    pub scroll_offset: usize,
998
999    /// Default line ending format for new files.
1000    /// Files loaded from disk will use their detected line ending format.
1001    /// Options: "lf" (Unix/Linux/macOS), "crlf" (Windows), "cr" (Classic Mac)
1002    /// Default: "lf"
1003    #[serde(default)]
1004    #[schemars(extend("x-section" = "Editing"))]
1005    pub default_line_ending: LineEndingOption,
1006
1007    /// Remove trailing whitespace from lines when saving.
1008    /// Default: false
1009    #[serde(default = "default_false")]
1010    #[schemars(extend("x-section" = "Editing"))]
1011    pub trim_trailing_whitespace_on_save: bool,
1012
1013    /// Ensure files end with a newline when saving.
1014    /// Default: false
1015    #[serde(default = "default_false")]
1016    #[schemars(extend("x-section" = "Editing"))]
1017    pub ensure_final_newline_on_save: bool,
1018
1019    // ===== Bracket Matching =====
1020    /// Highlight matching bracket pairs when cursor is on a bracket.
1021    /// Default: true
1022    #[serde(default = "default_true")]
1023    #[schemars(extend("x-section" = "Bracket Matching"))]
1024    pub highlight_matching_brackets: bool,
1025
1026    /// Use rainbow colors for nested brackets based on nesting depth.
1027    /// Requires highlight_matching_brackets to be enabled.
1028    /// Default: true
1029    #[serde(default = "default_true")]
1030    #[schemars(extend("x-section" = "Bracket Matching"))]
1031    pub rainbow_brackets: bool,
1032
1033    // ===== Completion =====
1034    /// Automatically show the completion popup while typing.
1035    /// When false (default), the popup only appears when explicitly invoked
1036    /// (e.g. via Ctrl+Space). When true, it appears automatically after a
1037    /// short delay while typing.
1038    /// Default: false
1039    #[serde(default = "default_false")]
1040    #[schemars(extend("x-section" = "Completion"))]
1041    pub completion_popup_auto_show: bool,
1042
1043    /// Enable quick suggestions (VS Code-like behavior).
1044    /// When enabled, completion suggestions appear automatically while typing,
1045    /// not just on trigger characters (like `.` or `::`).
1046    /// Only takes effect when completion_popup_auto_show is true.
1047    /// Default: true
1048    #[serde(default = "default_true")]
1049    #[schemars(extend("x-section" = "Completion"))]
1050    pub quick_suggestions: bool,
1051
1052    /// Delay in milliseconds before showing completion suggestions.
1053    /// Lower values (10-50ms) feel more responsive but may be distracting.
1054    /// Higher values (100-500ms) reduce noise while typing.
1055    /// Trigger characters (like `.`) bypass this delay.
1056    /// Default: 150
1057    #[serde(default = "default_quick_suggestions_delay")]
1058    #[schemars(extend("x-section" = "Completion"))]
1059    pub quick_suggestions_delay_ms: u64,
1060
1061    /// Whether trigger characters (like `.`, `::`, `->`) immediately show completions.
1062    /// When true, typing a trigger character bypasses quick_suggestions_delay_ms.
1063    /// Default: true
1064    #[serde(default = "default_true")]
1065    #[schemars(extend("x-section" = "Completion"))]
1066    pub suggest_on_trigger_characters: bool,
1067
1068    // ===== LSP =====
1069    /// Whether to enable LSP inlay hints (type hints, parameter hints, etc.)
1070    #[serde(default = "default_true")]
1071    #[schemars(extend("x-section" = "LSP"))]
1072    pub enable_inlay_hints: bool,
1073
1074    /// Whether to request full-document LSP semantic tokens.
1075    /// Range requests are still used when supported.
1076    /// Default: false (range-only to avoid heavy full refreshes).
1077    #[serde(default = "default_false")]
1078    #[schemars(extend("x-section" = "LSP"))]
1079    pub enable_semantic_tokens_full: bool,
1080
1081    /// Whether to show inline diagnostic text at the end of lines with errors/warnings.
1082    /// When enabled, the highest-severity diagnostic message is rendered after the
1083    /// source code on each affected line.
1084    /// Default: false
1085    #[serde(default = "default_false")]
1086    #[schemars(extend("x-section" = "Diagnostics"))]
1087    pub diagnostics_inline_text: bool,
1088
1089    // ===== Mouse =====
1090    /// Whether mouse hover triggers LSP hover requests.
1091    /// When enabled, hovering over code with the mouse will show documentation.
1092    /// On Windows, this also controls the mouse tracking mode: when disabled,
1093    /// the editor uses xterm mode 1002 (cell motion — click, drag, release only);
1094    /// when enabled, it uses mode 1003 (all motion — full mouse movement tracking).
1095    /// Mode 1003 generates high event volume on Windows and may cause input
1096    /// corruption on some systems. On macOS and Linux this setting only controls
1097    /// LSP hover; the mouse tracking mode is always full motion.
1098    /// Default: true (macOS/Linux), false (Windows)
1099    #[serde(default = "default_mouse_hover_enabled")]
1100    #[schemars(extend("x-section" = "Mouse"))]
1101    pub mouse_hover_enabled: bool,
1102
1103    /// Delay in milliseconds before a mouse hover triggers an LSP hover request.
1104    /// Lower values show hover info faster but may cause more LSP server load.
1105    /// Default: 500ms
1106    #[serde(default = "default_mouse_hover_delay")]
1107    #[schemars(extend("x-section" = "Mouse"))]
1108    pub mouse_hover_delay_ms: u64,
1109
1110    /// Time window in milliseconds for detecting double-clicks.
1111    /// Two clicks within this time are treated as a double-click (word selection).
1112    /// Default: 500ms
1113    #[serde(default = "default_double_click_time")]
1114    #[schemars(extend("x-section" = "Mouse"))]
1115    pub double_click_time_ms: u64,
1116
1117    /// Whether to enable persistent auto-save (save to original file on disk).
1118    /// When enabled, modified buffers are saved to their original file path
1119    /// at a configurable interval.
1120    /// Default: false
1121    #[serde(default = "default_false")]
1122    #[schemars(extend("x-section" = "Recovery"))]
1123    pub auto_save_enabled: bool,
1124
1125    /// Interval in seconds for persistent auto-save.
1126    /// Modified buffers are saved to their original file at this interval.
1127    /// Only effective when auto_save_enabled is true.
1128    /// Default: 30 seconds
1129    #[serde(default = "default_auto_save_interval")]
1130    #[schemars(extend("x-section" = "Recovery"))]
1131    pub auto_save_interval_secs: u32,
1132
1133    /// Whether to preserve unsaved changes in all buffers (file-backed and
1134    /// unnamed) across editor sessions (VS Code "hot exit" behavior).
1135    /// When enabled, modified buffers are backed up on clean exit and their
1136    /// unsaved changes are restored on next startup.  Unnamed (scratch)
1137    /// buffers are also persisted (Sublime Text / Notepad++ behavior).
1138    /// Default: true
1139    #[serde(default = "default_true", alias = "persist_unnamed_buffers")]
1140    #[schemars(extend("x-section" = "Recovery"))]
1141    pub hot_exit: bool,
1142
1143    /// Whether to auto-open previously opened files (session restore) when
1144    /// starting Fresh in a directory.  When enabled (the default), tabs,
1145    /// splits, cursor positions and the file explorer state are restored
1146    /// from the last clean exit in the same working directory.  When
1147    /// disabled, Fresh starts with a clean workspace.  The workspace file
1148    /// on disk is still written on exit, so re-enabling this setting picks
1149    /// up whatever state was saved at the most recent clean exit.  The
1150    /// `--no-restore` CLI flag is a stronger override: it skips both
1151    /// restoring and saving the workspace.
1152    /// Default: true
1153    #[serde(default = "default_true")]
1154    #[schemars(extend("x-section" = "Recovery"))]
1155    pub restore_previous_session: bool,
1156
1157    // ===== Recovery =====
1158    /// Whether to enable file recovery (Emacs-style auto-save)
1159    /// When enabled, buffers are periodically saved to recovery files
1160    /// so they can be recovered if the editor crashes.
1161    #[serde(default = "default_true")]
1162    #[schemars(extend("x-section" = "Recovery"))]
1163    pub recovery_enabled: bool,
1164
1165    /// Interval in seconds for auto-recovery-save.
1166    /// Modified buffers are saved to recovery files at this interval.
1167    /// Only effective when recovery_enabled is true.
1168    /// Default: 2 seconds
1169    #[serde(default = "default_auto_recovery_save_interval")]
1170    #[schemars(extend("x-section" = "Recovery"))]
1171    pub auto_recovery_save_interval_secs: u32,
1172
1173    /// Poll interval in milliseconds for auto-reverting open buffers.
1174    /// When auto-revert is enabled, file modification times are checked at this interval.
1175    /// Lower values detect external changes faster but use more CPU.
1176    /// Default: 2000ms (2 seconds)
1177    #[serde(default = "default_auto_revert_poll_interval")]
1178    #[schemars(extend("x-section" = "Recovery"))]
1179    pub auto_revert_poll_interval_ms: u64,
1180
1181    // ===== Keyboard =====
1182    /// Enable keyboard enhancement: disambiguate escape codes using CSI-u sequences.
1183    /// This allows unambiguous reading of Escape and modified keys.
1184    /// Requires terminal support (kitty keyboard protocol).
1185    /// Default: true
1186    #[serde(default = "default_true")]
1187    #[schemars(extend("x-section" = "Keyboard"))]
1188    pub keyboard_disambiguate_escape_codes: bool,
1189
1190    /// Enable keyboard enhancement: report key event types (repeat/release).
1191    /// Adds extra events when keys are autorepeated or released.
1192    /// Requires terminal support (kitty keyboard protocol).
1193    /// Default: false
1194    #[serde(default = "default_false")]
1195    #[schemars(extend("x-section" = "Keyboard"))]
1196    pub keyboard_report_event_types: bool,
1197
1198    /// Enable keyboard enhancement: report alternate keycodes.
1199    /// Sends alternate keycodes in addition to the base keycode.
1200    /// Requires terminal support (kitty keyboard protocol).
1201    /// Default: true
1202    #[serde(default = "default_true")]
1203    #[schemars(extend("x-section" = "Keyboard"))]
1204    pub keyboard_report_alternate_keys: bool,
1205
1206    /// Enable keyboard enhancement: report all keys as escape codes.
1207    /// Represents all keyboard events as CSI-u sequences.
1208    /// Required for repeat/release events on plain-text keys.
1209    /// Requires terminal support (kitty keyboard protocol).
1210    /// Default: false
1211    #[serde(default = "default_false")]
1212    #[schemars(extend("x-section" = "Keyboard"))]
1213    pub keyboard_report_all_keys_as_escape_codes: bool,
1214
1215    // ===== Performance =====
1216    /// Maximum time in milliseconds for syntax highlighting per frame
1217    #[serde(default = "default_highlight_timeout")]
1218    #[schemars(extend("x-section" = "Performance"))]
1219    pub highlight_timeout_ms: u64,
1220
1221    /// Undo history snapshot interval (number of edits between snapshots)
1222    #[serde(default = "default_snapshot_interval")]
1223    #[schemars(extend("x-section" = "Performance"))]
1224    pub snapshot_interval: usize,
1225
1226    /// Number of bytes to look back/forward from the viewport for syntax highlighting context.
1227    /// Larger values improve accuracy for multi-line constructs (strings, comments, nested blocks)
1228    /// but may slow down highlighting for very large files.
1229    /// Default: 10KB (10000 bytes)
1230    #[serde(default = "default_highlight_context_bytes")]
1231    #[schemars(extend("x-section" = "Performance"))]
1232    pub highlight_context_bytes: usize,
1233
1234    /// File size threshold in bytes for "large file" behavior
1235    /// Files larger than this will:
1236    /// - Skip LSP features
1237    /// - Use constant-size scrollbar thumb (1 char)
1238    ///
1239    /// Files smaller will count actual lines for accurate scrollbar rendering
1240    #[serde(default = "default_large_file_threshold")]
1241    #[schemars(extend("x-section" = "Performance"))]
1242    pub large_file_threshold_bytes: u64,
1243
1244    /// Estimated average line length in bytes (used for large file line estimation)
1245    /// This is used by LineIterator to estimate line positions in large files
1246    /// without line metadata. Typical values: 80-120 bytes.
1247    #[serde(default = "default_estimated_line_length")]
1248    #[schemars(extend("x-section" = "Performance"))]
1249    pub estimated_line_length: usize,
1250
1251    /// Maximum number of concurrent filesystem read requests.
1252    /// Used during line-feed scanning and other bulk I/O operations.
1253    /// Higher values improve throughput, especially for remote filesystems.
1254    /// Default: 64
1255    #[serde(default = "default_read_concurrency")]
1256    #[schemars(extend("x-section" = "Performance"))]
1257    pub read_concurrency: usize,
1258
1259    /// Poll interval in milliseconds for refreshing expanded directories in the file explorer.
1260    /// Directory modification times are checked at this interval to detect new/deleted files.
1261    /// Lower values detect changes faster but use more CPU.
1262    /// Default: 3000ms (3 seconds)
1263    #[serde(default = "default_file_tree_poll_interval")]
1264    #[schemars(extend("x-section" = "Performance"))]
1265    pub file_tree_poll_interval_ms: u64,
1266}
1267
1268fn default_tab_size() -> usize {
1269    4
1270}
1271
1272/// Large file threshold in bytes
1273/// Files larger than this will use optimized algorithms (estimation, viewport-only parsing)
1274/// Files smaller will use exact algorithms (full line tracking, complete parsing)
1275pub const LARGE_FILE_THRESHOLD_BYTES: u64 = 1024 * 1024; // 1MB
1276
1277fn default_large_file_threshold() -> u64 {
1278    LARGE_FILE_THRESHOLD_BYTES
1279}
1280
1281/// Maximum lines to scan forward when computing indent-based fold end
1282/// for the fold toggle action (user-triggered, infrequent).
1283pub const INDENT_FOLD_MAX_SCAN_LINES: usize = 10_000;
1284
1285/// Maximum lines to scan forward when checking foldability for gutter
1286/// indicators or click detection (called per-viewport-line during render).
1287pub const INDENT_FOLD_INDICATOR_MAX_SCAN: usize = 50;
1288
1289/// Maximum lines to walk backward when searching for a fold header
1290/// that contains the cursor (in the fold toggle action).
1291pub const INDENT_FOLD_MAX_UPWARD_SCAN: usize = 200;
1292
1293fn default_read_concurrency() -> usize {
1294    64
1295}
1296
1297fn default_true() -> bool {
1298    true
1299}
1300
1301fn default_false() -> bool {
1302    false
1303}
1304
1305fn default_quick_suggestions_delay() -> u64 {
1306    150 // 150ms — fast enough to feel responsive, slow enough to not interrupt typing
1307}
1308
1309fn default_scroll_offset() -> usize {
1310    3
1311}
1312
1313fn default_highlight_timeout() -> u64 {
1314    5
1315}
1316
1317fn default_snapshot_interval() -> usize {
1318    100
1319}
1320
1321fn default_estimated_line_length() -> usize {
1322    80
1323}
1324
1325fn default_auto_save_interval() -> u32 {
1326    30 // 30 seconds between persistent auto-saves
1327}
1328
1329fn default_auto_recovery_save_interval() -> u32 {
1330    2 // 2 seconds between recovery saves
1331}
1332
1333fn default_highlight_context_bytes() -> usize {
1334    10_000 // 10KB context for accurate syntax highlighting
1335}
1336
1337fn default_mouse_hover_enabled() -> bool {
1338    !cfg!(windows)
1339}
1340
1341fn default_mouse_hover_delay() -> u64 {
1342    500 // 500ms delay before showing hover info
1343}
1344
1345fn default_double_click_time() -> u64 {
1346    500 // 500ms window for detecting double-clicks
1347}
1348
1349fn default_auto_revert_poll_interval() -> u64 {
1350    2000 // 2 seconds between file mtime checks
1351}
1352
1353fn default_file_tree_poll_interval() -> u64 {
1354    3000 // 3 seconds between directory mtime checks
1355}
1356
1357impl Default for EditorConfig {
1358    fn default() -> Self {
1359        Self {
1360            use_tabs: false,
1361            tab_size: default_tab_size(),
1362            auto_indent: true,
1363            auto_close: true,
1364            auto_surround: true,
1365            animations: true,
1366            line_numbers: true,
1367            relative_line_numbers: false,
1368            scroll_offset: default_scroll_offset(),
1369            syntax_highlighting: true,
1370            highlight_current_line: true,
1371            highlight_current_column: false,
1372            line_wrap: true,
1373            wrap_indent: true,
1374            wrap_column: None,
1375            page_width: default_page_width(),
1376            highlight_timeout_ms: default_highlight_timeout(),
1377            snapshot_interval: default_snapshot_interval(),
1378            large_file_threshold_bytes: default_large_file_threshold(),
1379            estimated_line_length: default_estimated_line_length(),
1380            enable_inlay_hints: true,
1381            enable_semantic_tokens_full: false,
1382            diagnostics_inline_text: false,
1383            auto_save_enabled: false,
1384            auto_save_interval_secs: default_auto_save_interval(),
1385            hot_exit: true,
1386            restore_previous_session: true,
1387            recovery_enabled: true,
1388            auto_recovery_save_interval_secs: default_auto_recovery_save_interval(),
1389            highlight_context_bytes: default_highlight_context_bytes(),
1390            mouse_hover_enabled: default_mouse_hover_enabled(),
1391            mouse_hover_delay_ms: default_mouse_hover_delay(),
1392            double_click_time_ms: default_double_click_time(),
1393            auto_revert_poll_interval_ms: default_auto_revert_poll_interval(),
1394            read_concurrency: default_read_concurrency(),
1395            file_tree_poll_interval_ms: default_file_tree_poll_interval(),
1396            default_line_ending: LineEndingOption::default(),
1397            trim_trailing_whitespace_on_save: false,
1398            ensure_final_newline_on_save: false,
1399            highlight_matching_brackets: true,
1400            rainbow_brackets: true,
1401            cursor_style: CursorStyle::default(),
1402            keyboard_disambiguate_escape_codes: true,
1403            keyboard_report_event_types: false,
1404            keyboard_report_alternate_keys: true,
1405            keyboard_report_all_keys_as_escape_codes: false,
1406            completion_popup_auto_show: false,
1407            quick_suggestions: true,
1408            quick_suggestions_delay_ms: default_quick_suggestions_delay(),
1409            suggest_on_trigger_characters: true,
1410            show_menu_bar: true,
1411            menu_bar_mnemonics: true,
1412            show_tab_bar: true,
1413            show_status_bar: true,
1414            status_bar: StatusBarConfig::default(),
1415            show_prompt_line: true,
1416            show_vertical_scrollbar: true,
1417            show_horizontal_scrollbar: false,
1418            show_tilde: true,
1419            use_terminal_bg: false,
1420            set_window_title: true,
1421            rulers: Vec::new(),
1422            whitespace_show: true,
1423            whitespace_spaces_leading: false,
1424            whitespace_spaces_inner: false,
1425            whitespace_spaces_trailing: false,
1426            whitespace_tabs_leading: true,
1427            whitespace_tabs_inner: true,
1428            whitespace_tabs_trailing: true,
1429        }
1430    }
1431}
1432
1433/// File explorer configuration
1434#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1435pub struct FileExplorerConfig {
1436    /// Whether to respect .gitignore files
1437    #[serde(default = "default_true")]
1438    pub respect_gitignore: bool,
1439
1440    /// Whether to show hidden files (starting with .) by default
1441    #[serde(default = "default_false")]
1442    pub show_hidden: bool,
1443
1444    /// Whether to show gitignored files by default
1445    #[serde(default = "default_false")]
1446    pub show_gitignored: bool,
1447
1448    /// Custom patterns to ignore (in addition to .gitignore)
1449    #[serde(default)]
1450    pub custom_ignore_patterns: Vec<String>,
1451
1452    /// File explorer width. Either a percent (`"30%"`, 0–100) or an
1453    /// absolute column count (`"24"`). Legacy numeric forms are still
1454    /// accepted on read: a bare integer is treated as percent, and a
1455    /// fractional number in `[0, 1]` is treated as a legacy percent
1456    /// fraction (e.g. `0.3` → 30%).
1457    #[serde(default = "default_explorer_width")]
1458    pub width: ExplorerWidth,
1459
1460    /// Open files in a "preview" (ephemeral) tab on single-click in the
1461    /// file explorer. The preview tab is replaced by the next single-click
1462    /// instead of accumulating tabs. Editing the file, double-clicking
1463    /// (or pressing Enter) on it in the explorer, or dragging its tab
1464    /// promotes the tab to a permanent tab.
1465    /// Default: true
1466    #[serde(default = "default_true")]
1467    pub preview_tabs: bool,
1468}
1469
1470/// Width configuration for the file explorer.
1471///
1472/// Two forms are supported:
1473///
1474/// - `Percent(n)` — relative to the current terminal width. `n` is a
1475///   whole-percent value in `0..=100`.
1476/// - `Columns(n)` — absolute character columns. `n` is clamped at
1477///   render time against the live terminal width so the layout stays
1478///   inside the window.
1479///
1480/// ## Wire formats accepted on deserialize
1481///
1482/// | JSON form | Parsed as |
1483/// |---|---|
1484/// | `30` (integer) | `Percent(30)` |
1485/// | `0.3` (float in `[0, 1]`) | `Percent(30)` — legacy fraction |
1486/// | `1.5`, `30.0` (float outside `[0, 1]`) | `Percent(n)` |
1487/// | `"30%"`, `"30 %"` (string with `%`) | `Percent(30)` |
1488/// | `"24"` (string, no `%`) | `Columns(24)` |
1489///
1490/// ## Wire format emitted on serialize
1491///
1492/// - `Percent(n)` → string `"n%"`
1493/// - `Columns(n)` → string `"n"`
1494///
1495/// This makes `config.json` self-describing: the unit is visible on the
1496/// value, and round-trip is stable.
1497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1498pub enum ExplorerWidth {
1499    Percent(u8),
1500    Columns(u16),
1501}
1502
1503impl ExplorerWidth {
1504    /// Default width when none is configured.
1505    pub const DEFAULT: Self = Self::Percent(30);
1506
1507    /// Hard minimum for the *rendered* explorer width. Configured values
1508    /// smaller than this are accepted (so a hand-edited config round-trips
1509    /// cleanly) but the render path always gives the panel at least this
1510    /// many columns, preventing a 0- or 1-column explorer where the border
1511    /// stacks on itself and the drag-to-resize grip is unreachable.
1512    pub const MIN_COLS: u16 = 5;
1513
1514    /// Convert to terminal columns.
1515    ///
1516    /// `Percent` multiplies `terminal_width` by the percent; `Columns`
1517    /// returns the requested count. The result is then clamped to
1518    /// `MIN_COLS..=terminal_width` so it's always renderable and always
1519    /// usable. On terminals narrower than `MIN_COLS` the `terminal_width`
1520    /// cap wins and we return whatever fits.
1521    pub fn to_cols(self, terminal_width: u16) -> u16 {
1522        let raw = match self {
1523            Self::Percent(pct) => ((terminal_width as u32 * pct as u32) / 100) as u16,
1524            Self::Columns(cols) => cols,
1525        };
1526        raw.max(Self::MIN_COLS).min(terminal_width)
1527    }
1528}
1529
1530impl Default for ExplorerWidth {
1531    fn default() -> Self {
1532        Self::DEFAULT
1533    }
1534}
1535
1536impl std::fmt::Display for ExplorerWidth {
1537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1538        match self {
1539            Self::Percent(n) => write!(f, "{}%", n),
1540            Self::Columns(n) => write!(f, "{}", n),
1541        }
1542    }
1543}
1544
1545/// Parse error for `ExplorerWidth` strings.
1546#[derive(Debug)]
1547pub struct ExplorerWidthParseError(String);
1548
1549impl std::fmt::Display for ExplorerWidthParseError {
1550    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1551        write!(f, "{}", self.0)
1552    }
1553}
1554
1555impl std::error::Error for ExplorerWidthParseError {}
1556
1557impl std::str::FromStr for ExplorerWidth {
1558    type Err = ExplorerWidthParseError;
1559
1560    fn from_str(s: &str) -> Result<Self, Self::Err> {
1561        let s = s.trim();
1562        if s.is_empty() {
1563            return Err(ExplorerWidthParseError(
1564                "explorer width: empty string".into(),
1565            ));
1566        }
1567        if let Some(rest) = s.strip_suffix('%') {
1568            let n: u16 = rest.trim().parse().map_err(|_| {
1569                ExplorerWidthParseError(format!("explorer width: {:?} is not a valid percent", s))
1570            })?;
1571            if n > 100 {
1572                return Err(ExplorerWidthParseError(format!(
1573                    "explorer width: {}% exceeds 100%",
1574                    n
1575                )));
1576            }
1577            Ok(Self::Percent(n as u8))
1578        } else {
1579            let n: u16 = s.parse().map_err(|_| {
1580                ExplorerWidthParseError(format!(
1581                    "explorer width: {:?} is neither a percent (e.g. \"30%\") nor a column count (e.g. \"24\")",
1582                    s
1583                ))
1584            })?;
1585            Ok(Self::Columns(n))
1586        }
1587    }
1588}
1589
1590impl serde::Serialize for ExplorerWidth {
1591    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1592        s.collect_str(self)
1593    }
1594}
1595
1596impl<'de> serde::Deserialize<'de> for ExplorerWidth {
1597    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1598        let raw = serde_json::Value::deserialize(d)?;
1599        explorer_width::from_value(&raw)
1600    }
1601}
1602
1603impl schemars::JsonSchema for ExplorerWidth {
1604    fn schema_name() -> std::borrow::Cow<'static, str> {
1605        std::borrow::Cow::Borrowed("ExplorerWidth")
1606    }
1607
1608    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1609        // Declared as string (the canonical written form). Numbers are
1610        // still accepted on read via the custom Deserialize impl, for
1611        // back-compat with configs saved by older versions.
1612        schemars::json_schema!({
1613            "type": "string",
1614            "pattern": r"^(100%|[1-9]?[0-9]%|\d+)$",
1615            "description": "Either a percent like \"30%\" (0–100) or an absolute column count like \"24\".",
1616        })
1617    }
1618}
1619
1620fn default_explorer_width() -> ExplorerWidth {
1621    ExplorerWidth::DEFAULT
1622}
1623
1624/// Public default used by the workspace state deserializer.
1625pub fn default_explorer_width_value() -> ExplorerWidth {
1626    ExplorerWidth::DEFAULT
1627}
1628
1629/// Shared parsing logic for the custom `Deserialize` impl on
1630/// `ExplorerWidth` and for the `Option<ExplorerWidth>` variant used by
1631/// `PartialConfig`. Accepts all documented wire formats.
1632pub(crate) mod explorer_width {
1633    use super::ExplorerWidth;
1634    use serde::de::{self, Deserialize, Deserializer};
1635    use std::str::FromStr;
1636
1637    /// `Option<ExplorerWidth>` deserializer for `PartialConfig`.
1638    ///
1639    /// `null`/absent → `None`. Anything else is parsed by
1640    /// [`from_value`] and wrapped in `Some`.
1641    pub fn deserialize_optional<'de, D>(d: D) -> Result<Option<ExplorerWidth>, D::Error>
1642    where
1643        D: Deserializer<'de>,
1644    {
1645        let raw = Option::<serde_json::Value>::deserialize(d)?;
1646        match raw {
1647            None | Some(serde_json::Value::Null) => Ok(None),
1648            Some(v) => from_value(&v).map(Some),
1649        }
1650    }
1651
1652    pub(super) fn from_value<E: de::Error>(v: &serde_json::Value) -> Result<ExplorerWidth, E> {
1653        match v {
1654            serde_json::Value::String(s) => ExplorerWidth::from_str(s).map_err(E::custom),
1655            serde_json::Value::Number(n) => {
1656                if let Some(u) = n.as_u64() {
1657                    // Integer number — historical format for percent
1658                    // (post-#1118, pre-columns). Keep treating it as
1659                    // percent so existing configs stay correct.
1660                    if u > 100 {
1661                        return Err(E::custom(format!(
1662                            "explorer width: {} exceeds 100 (percent). Use \"{}\" for columns.",
1663                            u, u
1664                        )));
1665                    }
1666                    Ok(ExplorerWidth::Percent(u as u8))
1667                } else if let Some(f) = n.as_f64() {
1668                    // Float: legacy fraction in [0, 1] OR explicit percent.
1669                    let pct = if (0.0..=1.0).contains(&f) {
1670                        f * 100.0
1671                    } else {
1672                        f
1673                    };
1674                    if !(0.0..=100.0).contains(&pct) {
1675                        return Err(E::custom(format!(
1676                            "explorer width: percent {} out of range 0..=100",
1677                            pct
1678                        )));
1679                    }
1680                    Ok(ExplorerWidth::Percent(pct.round() as u8))
1681                } else {
1682                    Err(E::custom("explorer width: unsupported number"))
1683                }
1684            }
1685            _ => Err(E::custom(
1686                "explorer width: expected \"30%\", \"24\" (columns), or a number",
1687            )),
1688        }
1689    }
1690}
1691
1692/// Clipboard configuration
1693///
1694/// Controls which clipboard methods are used for copy/paste operations.
1695/// By default, all methods are enabled and the editor tries them in order:
1696/// 1. OSC 52 escape sequences (works in modern terminals like Kitty, Alacritty, Wezterm)
1697/// 2. System clipboard via X11/Wayland APIs (works in Gnome Console, XFCE Terminal, etc.)
1698/// 3. Internal clipboard (always available as fallback)
1699///
1700/// If you experience hangs or issues (e.g., when using PuTTY or certain SSH setups),
1701/// you can disable specific methods.
1702#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1703pub struct ClipboardConfig {
1704    /// Enable OSC 52 escape sequences for clipboard access (default: true)
1705    /// Disable this if your terminal doesn't support OSC 52 or if it causes hangs
1706    #[serde(default = "default_true")]
1707    pub use_osc52: bool,
1708
1709    /// Enable system clipboard access via X11/Wayland APIs (default: true)
1710    /// Disable this if you don't have a display server or it causes issues
1711    #[serde(default = "default_true")]
1712    pub use_system_clipboard: bool,
1713}
1714
1715impl Default for ClipboardConfig {
1716    fn default() -> Self {
1717        Self {
1718            use_osc52: true,
1719            use_system_clipboard: true,
1720        }
1721    }
1722}
1723
1724/// Terminal configuration
1725#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1726pub struct TerminalConfig {
1727    /// When viewing terminal scrollback and new output arrives,
1728    /// automatically jump back to terminal mode (default: true)
1729    #[serde(default = "default_true")]
1730    pub jump_to_end_on_output: bool,
1731
1732    /// Override the shell used by the integrated terminal.
1733    ///
1734    /// When unset (the default), Fresh launches the shell named by the
1735    /// `$SHELL` environment variable (or the platform default if `$SHELL`
1736    /// is empty). Set this to run a different program — for example a
1737    /// wrapper script that forces an interactive shell — without having
1738    /// to change `$SHELL` for the whole process, which other features
1739    /// such as `format_on_save` also depend on.
1740    ///
1741    /// Only affects local authorities; plugin-provided authorities
1742    /// (e.g. `docker exec`) keep their own wrapper.
1743    #[serde(default)]
1744    pub shell: Option<TerminalShellConfig>,
1745}
1746
1747impl Default for TerminalConfig {
1748    fn default() -> Self {
1749        Self {
1750            jump_to_end_on_output: true,
1751            shell: None,
1752        }
1753    }
1754}
1755
1756/// Explicit shell command + args for the integrated terminal.
1757#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1758pub struct TerminalShellConfig {
1759    /// Executable to launch (e.g. `/usr/bin/fish`, `bash`, or a wrapper
1760    /// script). Resolved via `$PATH` when not absolute.
1761    pub command: String,
1762
1763    /// Arguments passed before any user input.
1764    #[serde(default)]
1765    pub args: Vec<String>,
1766}
1767
1768/// Warning notification configuration
1769#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1770pub struct WarningsConfig {
1771    /// Show warning/error indicators in the status bar (default: true)
1772    /// When enabled, displays a colored indicator for LSP errors and other warnings
1773    #[serde(default = "default_true")]
1774    pub show_status_indicator: bool,
1775}
1776
1777impl Default for WarningsConfig {
1778    fn default() -> Self {
1779        Self {
1780            show_status_indicator: true,
1781        }
1782    }
1783}
1784
1785/// Package manager configuration for plugins and themes
1786#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1787pub struct PackagesConfig {
1788    /// Registry sources (git repository URLs containing plugin/theme indices)
1789    /// Default: ["https://github.com/sinelaw/fresh-plugins-registry"]
1790    #[serde(default = "default_package_sources")]
1791    pub sources: Vec<String>,
1792}
1793
1794fn default_package_sources() -> Vec<String> {
1795    vec!["https://github.com/sinelaw/fresh-plugins-registry".to_string()]
1796}
1797
1798impl Default for PackagesConfig {
1799    fn default() -> Self {
1800        Self {
1801            sources: default_package_sources(),
1802        }
1803    }
1804}
1805
1806// Re-export PluginConfig from fresh-core for shared type usage
1807pub use fresh_core::config::PluginConfig;
1808
1809impl Default for FileExplorerConfig {
1810    fn default() -> Self {
1811        Self {
1812            respect_gitignore: true,
1813            show_hidden: false,
1814            show_gitignored: false,
1815            custom_ignore_patterns: Vec::new(),
1816            width: default_explorer_width(),
1817            preview_tabs: true,
1818        }
1819    }
1820}
1821
1822/// File browser configuration (for Open File dialog)
1823#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1824pub struct FileBrowserConfig {
1825    /// Whether to show hidden files (starting with .) by default in Open File dialog
1826    #[serde(default = "default_false")]
1827    pub show_hidden: bool,
1828}
1829
1830/// A single key in a sequence
1831#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1832pub struct KeyPress {
1833    /// Key name (e.g., "a", "Enter", "F1")
1834    pub key: String,
1835    /// Modifiers (e.g., ["ctrl"], ["ctrl", "shift"])
1836    #[serde(default)]
1837    pub modifiers: Vec<String>,
1838}
1839
1840/// Keybinding definition
1841#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1842#[schemars(extend("x-display-field" = "/action"))]
1843pub struct Keybinding {
1844    /// Key name (e.g., "a", "Enter", "F1") - for single-key bindings
1845    #[serde(default, skip_serializing_if = "String::is_empty")]
1846    pub key: String,
1847
1848    /// Modifiers (e.g., ["ctrl"], ["ctrl", "shift"]) - for single-key bindings
1849    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1850    pub modifiers: Vec<String>,
1851
1852    /// Key sequence for chord bindings (e.g., [{"key": "x", "modifiers": ["ctrl"]}, {"key": "s", "modifiers": ["ctrl"]}])
1853    /// If present, takes precedence over key + modifiers
1854    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1855    pub keys: Vec<KeyPress>,
1856
1857    /// Action to perform (e.g., "insert_char", "move_left")
1858    pub action: String,
1859
1860    /// Optional arguments for the action
1861    #[serde(default)]
1862    pub args: HashMap<String, serde_json::Value>,
1863
1864    /// Optional condition (e.g., "mode == insert")
1865    #[serde(default)]
1866    pub when: Option<String>,
1867}
1868
1869/// Keymap configuration (for built-in and user-defined keymaps)
1870#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1871#[schemars(extend("x-display-field" = "/inherits"))]
1872pub struct KeymapConfig {
1873    /// Optional parent keymap to inherit from
1874    #[serde(default, skip_serializing_if = "Option::is_none")]
1875    pub inherits: Option<String>,
1876
1877    /// Keybindings defined in this keymap
1878    #[serde(default)]
1879    pub bindings: Vec<Keybinding>,
1880}
1881
1882/// Formatter configuration for a language
1883#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1884#[schemars(extend("x-display-field" = "/command"))]
1885pub struct FormatterConfig {
1886    /// The formatter command to run (e.g., "rustfmt", "prettier")
1887    pub command: String,
1888
1889    /// Arguments to pass to the formatter
1890    /// Use "$FILE" to include the file path
1891    #[serde(default)]
1892    pub args: Vec<String>,
1893
1894    /// Whether to pass buffer content via stdin (default: true)
1895    /// Most formatters read from stdin and write to stdout
1896    #[serde(default = "default_true")]
1897    pub stdin: bool,
1898
1899    /// Timeout in milliseconds (default: 10000)
1900    #[serde(default = "default_on_save_timeout")]
1901    pub timeout_ms: u64,
1902}
1903
1904/// Action to run when a file is saved (for linters, etc.)
1905#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1906#[schemars(extend("x-display-field" = "/command"))]
1907pub struct OnSaveAction {
1908    /// The shell command to run
1909    /// The file path is available as $FILE or as an argument
1910    pub command: String,
1911
1912    /// Arguments to pass to the command
1913    /// Use "$FILE" to include the file path
1914    #[serde(default)]
1915    pub args: Vec<String>,
1916
1917    /// Working directory for the command (defaults to project root)
1918    #[serde(default)]
1919    pub working_dir: Option<String>,
1920
1921    /// Whether to use the buffer content as stdin
1922    #[serde(default)]
1923    pub stdin: bool,
1924
1925    /// Timeout in milliseconds (default: 10000)
1926    #[serde(default = "default_on_save_timeout")]
1927    pub timeout_ms: u64,
1928
1929    /// Whether this action is enabled (default: true)
1930    /// Set to false to disable an action without removing it from config
1931    #[serde(default = "default_true")]
1932    pub enabled: bool,
1933}
1934
1935fn default_on_save_timeout() -> u64 {
1936    10000
1937}
1938
1939fn default_page_width() -> Option<usize> {
1940    Some(80)
1941}
1942
1943/// Language-specific configuration
1944#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1945#[schemars(extend("x-display-field" = "/grammar"))]
1946pub struct LanguageConfig {
1947    /// File extensions for this language (e.g., ["rs"] for Rust)
1948    #[serde(default)]
1949    pub extensions: Vec<String>,
1950
1951    /// Exact filenames for this language (e.g., ["Makefile", "GNUmakefile"])
1952    #[serde(default)]
1953    pub filenames: Vec<String>,
1954
1955    /// Tree-sitter grammar name
1956    #[serde(default)]
1957    pub grammar: String,
1958
1959    /// Comment prefix
1960    #[serde(default)]
1961    pub comment_prefix: Option<String>,
1962
1963    /// Whether to auto-indent
1964    #[serde(default = "default_true")]
1965    pub auto_indent: bool,
1966
1967    /// Whether to auto-close brackets, parentheses, and quotes for this language.
1968    /// If not specified (`null`), falls back to the global `editor.auto_close` setting.
1969    #[serde(default)]
1970    pub auto_close: Option<bool>,
1971
1972    /// Whether to auto-surround selected text with matching pairs for this language.
1973    /// If not specified (`null`), falls back to the global `editor.auto_surround` setting.
1974    #[serde(default)]
1975    pub auto_surround: Option<bool>,
1976
1977    /// Path to custom TextMate grammar file (optional)
1978    /// If specified, this grammar will be used when highlighter is "textmate"
1979    #[serde(default)]
1980    pub textmate_grammar: Option<std::path::PathBuf>,
1981
1982    /// Whether to show whitespace tab indicators (→) for this language
1983    /// Defaults to true. Set to false for languages like Go that use tabs for indentation.
1984    #[serde(default = "default_true")]
1985    pub show_whitespace_tabs: bool,
1986
1987    /// Whether to enable line wrapping for this language.
1988    /// If not specified (`null`), falls back to the global `editor.line_wrap` setting.
1989    /// Useful for prose-heavy languages like Markdown where wrapping is desirable
1990    /// even if globally disabled.
1991    #[serde(default)]
1992    pub line_wrap: Option<bool>,
1993
1994    /// Column at which to wrap lines for this language.
1995    /// If not specified (`null`), falls back to the global `editor.wrap_column` setting.
1996    #[serde(default)]
1997    pub wrap_column: Option<usize>,
1998
1999    /// Whether to automatically enable page view (compose mode) for this language.
2000    /// Page view provides a document-style layout with centered content,
2001    /// concealed formatting markers, and intelligent word wrapping.
2002    /// If not specified (`null`), page view is not auto-activated.
2003    #[serde(default)]
2004    pub page_view: Option<bool>,
2005
2006    /// Width of the page in page view mode (in columns).
2007    /// Controls the content width when page view is active, with centering margins.
2008    /// If not specified (`null`), falls back to the global `editor.page_width` setting.
2009    #[serde(default)]
2010    pub page_width: Option<usize>,
2011
2012    /// Whether pressing Tab should insert a tab character instead of spaces.
2013    /// If not specified (`null`), falls back to the global `editor.use_tabs` setting.
2014    /// Set to true for languages like Go and Makefile that require tabs.
2015    #[serde(default)]
2016    pub use_tabs: Option<bool>,
2017
2018    /// Tab size (number of spaces per tab) for this language.
2019    /// If not specified, falls back to the global editor.tab_size setting.
2020    #[serde(default)]
2021    pub tab_size: Option<usize>,
2022
2023    /// The formatter for this language (used by format_buffer command)
2024    #[serde(default)]
2025    pub formatter: Option<FormatterConfig>,
2026
2027    /// Whether to automatically format on save (uses the formatter above)
2028    #[serde(default)]
2029    pub format_on_save: bool,
2030
2031    /// Actions to run when a file of this language is saved (linters, etc.)
2032    /// Actions are run in order; if any fails (non-zero exit), subsequent actions don't run
2033    /// Note: Use `formatter` + `format_on_save` for formatting, not on_save
2034    #[serde(default)]
2035    pub on_save: Vec<OnSaveAction>,
2036
2037    /// Extra characters (beyond alphanumeric and `_`) considered part of
2038    /// identifiers for this language. Used by dabbrev and buffer-word
2039    /// completion to correctly tokenise language-specific naming conventions.
2040    ///
2041    /// Examples:
2042    /// - Lisp/Clojure/CSS: `"-"` (kebab-case identifiers)
2043    /// - PHP/Bash: `"$"` (variable sigils)
2044    /// - Ruby: `"?!"` (predicate/bang methods)
2045    /// - Rust (default): `""` (standard alphanumeric + underscore)
2046    #[serde(default)]
2047    pub word_characters: Option<String>,
2048}
2049
2050/// Resolved editor configuration for a specific buffer.
2051///
2052/// This struct contains the effective settings for a buffer after applying
2053/// language-specific overrides on top of the global editor config.
2054///
2055/// Use `BufferConfig::resolve()` to create one from a Config and optional language ID.
2056#[derive(Debug, Clone)]
2057pub struct BufferConfig {
2058    /// Number of spaces per tab character
2059    pub tab_size: usize,
2060
2061    /// Whether to insert a tab character (true) or spaces (false) when pressing Tab
2062    pub use_tabs: bool,
2063
2064    /// Whether to auto-indent new lines
2065    pub auto_indent: bool,
2066
2067    /// Whether to auto-close brackets, parentheses, and quotes
2068    pub auto_close: bool,
2069
2070    /// Whether to surround selected text with matching pairs
2071    pub auto_surround: bool,
2072
2073    /// Whether line wrapping is enabled for this buffer
2074    pub line_wrap: bool,
2075
2076    /// Column at which to wrap lines (None = viewport width)
2077    pub wrap_column: Option<usize>,
2078
2079    /// Resolved whitespace indicator visibility
2080    pub whitespace: WhitespaceVisibility,
2081
2082    /// Formatter command for this buffer
2083    pub formatter: Option<FormatterConfig>,
2084
2085    /// Whether to format on save
2086    pub format_on_save: bool,
2087
2088    /// Actions to run when saving
2089    pub on_save: Vec<OnSaveAction>,
2090
2091    /// Path to custom TextMate grammar (if any)
2092    pub textmate_grammar: Option<std::path::PathBuf>,
2093
2094    /// Extra word-constituent characters for this language (for completion).
2095    /// Empty string means standard alphanumeric + underscore only.
2096    pub word_characters: String,
2097}
2098
2099impl BufferConfig {
2100    /// Resolve the effective configuration for a buffer given its language.
2101    ///
2102    /// This merges the global editor settings with any language-specific overrides
2103    /// from `Config.languages`.
2104    ///
2105    /// # Arguments
2106    /// * `global_config` - The resolved global configuration
2107    /// * `language_id` - Optional language identifier (e.g., "rust", "python")
2108    pub fn resolve(global_config: &Config, language_id: Option<&str>) -> Self {
2109        let editor = &global_config.editor;
2110
2111        // Start with global editor settings
2112        let mut whitespace = WhitespaceVisibility::from_editor_config(editor);
2113        let mut config = BufferConfig {
2114            tab_size: editor.tab_size,
2115            use_tabs: editor.use_tabs,
2116            auto_indent: editor.auto_indent,
2117            auto_close: editor.auto_close,
2118            auto_surround: editor.auto_surround,
2119            line_wrap: editor.line_wrap,
2120            wrap_column: editor.wrap_column,
2121            whitespace,
2122            formatter: None,
2123            format_on_save: false,
2124            on_save: Vec::new(),
2125            textmate_grammar: None,
2126            word_characters: String::new(),
2127        };
2128
2129        // Apply language-specific overrides if available.
2130        // If no language config matches and the language is "text" (undetected),
2131        // try the default_language config (#1219).
2132        let lang_config_ref = language_id
2133            .and_then(|id| global_config.languages.get(id))
2134            .or_else(|| {
2135                // Apply default_language only when language is unknown ("text" or None)
2136                match language_id {
2137                    None | Some("text") => global_config
2138                        .default_language
2139                        .as_deref()
2140                        .and_then(|lang| global_config.languages.get(lang)),
2141                    _ => None,
2142                }
2143            });
2144        if let Some(lang_config) = lang_config_ref {
2145            // Tab size: use language setting if specified, else global
2146            if let Some(ts) = lang_config.tab_size {
2147                config.tab_size = ts;
2148            }
2149
2150            // Use tabs: language override (only if explicitly set)
2151            if let Some(use_tabs) = lang_config.use_tabs {
2152                config.use_tabs = use_tabs;
2153            }
2154
2155            // Line wrap: language override (only if explicitly set)
2156            if let Some(line_wrap) = lang_config.line_wrap {
2157                config.line_wrap = line_wrap;
2158            }
2159
2160            // Wrap column: language override (only if explicitly set)
2161            if lang_config.wrap_column.is_some() {
2162                config.wrap_column = lang_config.wrap_column;
2163            }
2164
2165            // Auto indent: language override
2166            config.auto_indent = lang_config.auto_indent;
2167
2168            // Auto close: language override (only if globally enabled)
2169            if config.auto_close {
2170                if let Some(lang_auto_close) = lang_config.auto_close {
2171                    config.auto_close = lang_auto_close;
2172                }
2173            }
2174
2175            // Auto surround: language override (only if globally enabled)
2176            if config.auto_surround {
2177                if let Some(lang_auto_surround) = lang_config.auto_surround {
2178                    config.auto_surround = lang_auto_surround;
2179                }
2180            }
2181
2182            // Whitespace tabs: language override can disable tab indicators
2183            whitespace = whitespace.with_language_tab_override(lang_config.show_whitespace_tabs);
2184            config.whitespace = whitespace;
2185
2186            // Formatter: from language config
2187            config.formatter = lang_config.formatter.clone();
2188
2189            // Format on save: from language config
2190            config.format_on_save = lang_config.format_on_save;
2191
2192            // On save actions: from language config
2193            config.on_save = lang_config.on_save.clone();
2194
2195            // TextMate grammar path: from language config
2196            config.textmate_grammar = lang_config.textmate_grammar.clone();
2197
2198            // Word characters: from language config
2199            if let Some(ref wc) = lang_config.word_characters {
2200                config.word_characters = wc.clone();
2201            }
2202        }
2203
2204        config
2205    }
2206
2207    /// Get the effective indentation string for this buffer.
2208    ///
2209    /// Returns a tab character if `use_tabs` is true, otherwise returns
2210    /// `tab_size` spaces.
2211    pub fn indent_string(&self) -> String {
2212        if self.use_tabs {
2213            "\t".to_string()
2214        } else {
2215            " ".repeat(self.tab_size)
2216        }
2217    }
2218}
2219
2220/// Menu bar configuration
2221#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
2222pub struct MenuConfig {
2223    /// List of top-level menus in the menu bar
2224    #[serde(default)]
2225    pub menus: Vec<Menu>,
2226}
2227
2228// Re-export Menu and MenuItem from fresh-core for shared type usage
2229pub use fresh_core::menu::{Menu, MenuItem};
2230
2231/// Extension trait for Menu with editor-specific functionality
2232pub trait MenuExt {
2233    /// Get the identifier for matching (id if set, otherwise label).
2234    /// This is used for keybinding matching and should be stable across translations.
2235    fn match_id(&self) -> &str;
2236
2237    /// Expand all DynamicSubmenu items in this menu to regular Submenu items
2238    /// This should be called before the menu is used for rendering/navigation
2239    fn expand_dynamic_items(&mut self, themes_dir: &std::path::Path);
2240}
2241
2242impl MenuExt for Menu {
2243    fn match_id(&self) -> &str {
2244        self.id.as_deref().unwrap_or(&self.label)
2245    }
2246
2247    fn expand_dynamic_items(&mut self, themes_dir: &std::path::Path) {
2248        self.items = self
2249            .items
2250            .iter()
2251            .map(|item| item.expand_dynamic(themes_dir))
2252            .collect();
2253    }
2254}
2255
2256/// Extension trait for MenuItem with editor-specific functionality
2257pub trait MenuItemExt {
2258    /// Expand a DynamicSubmenu into a regular Submenu with generated items.
2259    /// Returns the original item if not a DynamicSubmenu.
2260    fn expand_dynamic(&self, themes_dir: &std::path::Path) -> MenuItem;
2261}
2262
2263impl MenuItemExt for MenuItem {
2264    fn expand_dynamic(&self, themes_dir: &std::path::Path) -> MenuItem {
2265        match self {
2266            MenuItem::DynamicSubmenu { label, source } => {
2267                let items = generate_dynamic_items(source, themes_dir);
2268                MenuItem::Submenu {
2269                    label: label.clone(),
2270                    items,
2271                }
2272            }
2273            other => other.clone(),
2274        }
2275    }
2276}
2277
2278/// Generate menu items for a dynamic source (runtime only - requires view::theme)
2279#[cfg(feature = "runtime")]
2280pub fn generate_dynamic_items(source: &str, themes_dir: &std::path::Path) -> Vec<MenuItem> {
2281    match source {
2282        "copy_with_theme" => {
2283            // Generate theme options from available themes
2284            let loader = crate::view::theme::ThemeLoader::new(themes_dir.to_path_buf());
2285            let registry = loader.load_all(&[]);
2286            registry
2287                .list()
2288                .iter()
2289                .map(|info| {
2290                    let mut args = HashMap::new();
2291                    args.insert("theme".to_string(), serde_json::json!(info.key));
2292                    MenuItem::Action {
2293                        label: info.name.clone(),
2294                        action: "copy_with_theme".to_string(),
2295                        args,
2296                        when: Some(context_keys::HAS_SELECTION.to_string()),
2297                        checkbox: None,
2298                    }
2299                })
2300                .collect()
2301        }
2302        _ => vec![MenuItem::Label {
2303            info: format!("Unknown source: {}", source),
2304        }],
2305    }
2306}
2307
2308/// Generate menu items for a dynamic source (WASM stub - returns empty)
2309#[cfg(not(feature = "runtime"))]
2310pub fn generate_dynamic_items(_source: &str, _themes_dir: &std::path::Path) -> Vec<MenuItem> {
2311    // Theme loading not available in WASM builds
2312    vec![]
2313}
2314
2315impl Default for Config {
2316    fn default() -> Self {
2317        Self {
2318            version: 0,
2319            theme: default_theme_name(),
2320            locale: LocaleName::default(),
2321            check_for_updates: true,
2322            editor: EditorConfig::default(),
2323            file_explorer: FileExplorerConfig::default(),
2324            file_browser: FileBrowserConfig::default(),
2325            clipboard: ClipboardConfig::default(),
2326            terminal: TerminalConfig::default(),
2327            keybindings: vec![], // User customizations only; defaults come from active_keybinding_map
2328            keybinding_maps: HashMap::new(), // User-defined maps go here
2329            active_keybinding_map: default_keybinding_map_name(),
2330            languages: Self::default_languages(),
2331            default_language: None,
2332            lsp: Self::default_lsp_config(),
2333            universal_lsp: Self::default_universal_lsp_config(),
2334            warnings: WarningsConfig::default(),
2335            plugins: HashMap::new(), // Populated when scanning for plugins
2336            packages: PackagesConfig::default(),
2337        }
2338    }
2339}
2340
2341impl MenuConfig {
2342    /// Create a MenuConfig with translated menus using the current locale
2343    pub fn translated() -> Self {
2344        Self {
2345            menus: Self::translated_menus(),
2346        }
2347    }
2348
2349    /// Create default menu bar configuration with translated labels.
2350    ///
2351    /// This is the single source of truth for the editor's menu structure.
2352    /// Both the built-in TUI menu bar and the native GUI menu bar (e.g. macOS)
2353    /// are built from this definition.
2354    pub fn translated_menus() -> Vec<Menu> {
2355        vec![
2356            // File menu
2357            Menu {
2358                id: Some("File".to_string()),
2359                label: t!("menu.file").to_string(),
2360                when: None,
2361                items: vec![
2362                    MenuItem::Action {
2363                        label: t!("menu.file.new_file").to_string(),
2364                        action: "new".to_string(),
2365                        args: HashMap::new(),
2366                        when: None,
2367                        checkbox: None,
2368                    },
2369                    MenuItem::Action {
2370                        label: t!("menu.file.open_file").to_string(),
2371                        action: "open".to_string(),
2372                        args: HashMap::new(),
2373                        when: None,
2374                        checkbox: None,
2375                    },
2376                    MenuItem::Separator { separator: true },
2377                    MenuItem::Action {
2378                        label: t!("menu.file.save").to_string(),
2379                        action: "save".to_string(),
2380                        args: HashMap::new(),
2381                        when: None,
2382                        checkbox: None,
2383                    },
2384                    MenuItem::Action {
2385                        label: t!("menu.file.save_as").to_string(),
2386                        action: "save_as".to_string(),
2387                        args: HashMap::new(),
2388                        when: None,
2389                        checkbox: None,
2390                    },
2391                    MenuItem::Action {
2392                        label: t!("menu.file.revert").to_string(),
2393                        action: "revert".to_string(),
2394                        args: HashMap::new(),
2395                        when: None,
2396                        checkbox: None,
2397                    },
2398                    MenuItem::Action {
2399                        label: t!("menu.file.reload_with_encoding").to_string(),
2400                        action: "reload_with_encoding".to_string(),
2401                        args: HashMap::new(),
2402                        when: None,
2403                        checkbox: None,
2404                    },
2405                    MenuItem::Separator { separator: true },
2406                    MenuItem::Action {
2407                        label: t!("menu.file.close_buffer").to_string(),
2408                        action: "close".to_string(),
2409                        args: HashMap::new(),
2410                        when: None,
2411                        checkbox: None,
2412                    },
2413                    MenuItem::Separator { separator: true },
2414                    MenuItem::Action {
2415                        label: t!("menu.file.switch_project").to_string(),
2416                        action: "switch_project".to_string(),
2417                        args: HashMap::new(),
2418                        when: None,
2419                        checkbox: None,
2420                    },
2421                    MenuItem::Separator { separator: true },
2422                    MenuItem::Action {
2423                        label: t!("menu.file.detach").to_string(),
2424                        action: "detach".to_string(),
2425                        args: HashMap::new(),
2426                        when: Some(context_keys::SESSION_MODE.to_string()),
2427                        checkbox: None,
2428                    },
2429                    MenuItem::Action {
2430                        label: t!("menu.file.quit").to_string(),
2431                        action: "quit".to_string(),
2432                        args: HashMap::new(),
2433                        when: None,
2434                        checkbox: None,
2435                    },
2436                ],
2437            },
2438            // Edit menu
2439            Menu {
2440                id: Some("Edit".to_string()),
2441                label: t!("menu.edit").to_string(),
2442                when: None,
2443                items: vec![
2444                    MenuItem::Action {
2445                        label: t!("menu.edit.undo").to_string(),
2446                        action: "undo".to_string(),
2447                        args: HashMap::new(),
2448                        when: None,
2449                        checkbox: None,
2450                    },
2451                    MenuItem::Action {
2452                        label: t!("menu.edit.redo").to_string(),
2453                        action: "redo".to_string(),
2454                        args: HashMap::new(),
2455                        when: None,
2456                        checkbox: None,
2457                    },
2458                    MenuItem::Separator { separator: true },
2459                    MenuItem::Action {
2460                        label: t!("menu.edit.cut").to_string(),
2461                        action: "cut".to_string(),
2462                        args: HashMap::new(),
2463                        when: Some(context_keys::CAN_COPY.to_string()),
2464                        checkbox: None,
2465                    },
2466                    MenuItem::Action {
2467                        label: t!("menu.edit.copy").to_string(),
2468                        action: "copy".to_string(),
2469                        args: HashMap::new(),
2470                        when: Some(context_keys::CAN_COPY.to_string()),
2471                        checkbox: None,
2472                    },
2473                    MenuItem::DynamicSubmenu {
2474                        label: t!("menu.edit.copy_with_formatting").to_string(),
2475                        source: "copy_with_theme".to_string(),
2476                    },
2477                    MenuItem::Action {
2478                        label: t!("menu.edit.paste").to_string(),
2479                        action: "paste".to_string(),
2480                        args: HashMap::new(),
2481                        when: Some(context_keys::CAN_PASTE.to_string()),
2482                        checkbox: None,
2483                    },
2484                    MenuItem::Separator { separator: true },
2485                    MenuItem::Action {
2486                        label: t!("menu.edit.select_all").to_string(),
2487                        action: "select_all".to_string(),
2488                        args: HashMap::new(),
2489                        when: None,
2490                        checkbox: None,
2491                    },
2492                    MenuItem::Separator { separator: true },
2493                    MenuItem::Action {
2494                        label: t!("menu.edit.find").to_string(),
2495                        action: "search".to_string(),
2496                        args: HashMap::new(),
2497                        when: None,
2498                        checkbox: None,
2499                    },
2500                    MenuItem::Action {
2501                        label: t!("menu.edit.find_in_selection").to_string(),
2502                        action: "find_in_selection".to_string(),
2503                        args: HashMap::new(),
2504                        when: Some(context_keys::HAS_SELECTION.to_string()),
2505                        checkbox: None,
2506                    },
2507                    MenuItem::Action {
2508                        label: t!("menu.edit.find_next").to_string(),
2509                        action: "find_next".to_string(),
2510                        args: HashMap::new(),
2511                        when: None,
2512                        checkbox: None,
2513                    },
2514                    MenuItem::Action {
2515                        label: t!("menu.edit.find_previous").to_string(),
2516                        action: "find_previous".to_string(),
2517                        args: HashMap::new(),
2518                        when: None,
2519                        checkbox: None,
2520                    },
2521                    MenuItem::Action {
2522                        label: t!("menu.edit.replace").to_string(),
2523                        action: "query_replace".to_string(),
2524                        args: HashMap::new(),
2525                        when: None,
2526                        checkbox: None,
2527                    },
2528                    MenuItem::Separator { separator: true },
2529                    MenuItem::Action {
2530                        label: t!("menu.edit.delete_line").to_string(),
2531                        action: "delete_line".to_string(),
2532                        args: HashMap::new(),
2533                        when: None,
2534                        checkbox: None,
2535                    },
2536                    MenuItem::Action {
2537                        label: t!("menu.edit.format_buffer").to_string(),
2538                        action: "format_buffer".to_string(),
2539                        args: HashMap::new(),
2540                        when: Some(context_keys::FORMATTER_AVAILABLE.to_string()),
2541                        checkbox: None,
2542                    },
2543                    MenuItem::Separator { separator: true },
2544                    MenuItem::Action {
2545                        label: t!("menu.edit.settings").to_string(),
2546                        action: "open_settings".to_string(),
2547                        args: HashMap::new(),
2548                        when: None,
2549                        checkbox: None,
2550                    },
2551                    MenuItem::Action {
2552                        label: t!("menu.edit.keybinding_editor").to_string(),
2553                        action: "open_keybinding_editor".to_string(),
2554                        args: HashMap::new(),
2555                        when: None,
2556                        checkbox: None,
2557                    },
2558                ],
2559            },
2560            // View menu
2561            Menu {
2562                id: Some("View".to_string()),
2563                label: t!("menu.view").to_string(),
2564                when: None,
2565                items: vec![
2566                    MenuItem::Action {
2567                        label: t!("menu.view.file_explorer").to_string(),
2568                        action: "toggle_file_explorer".to_string(),
2569                        args: HashMap::new(),
2570                        when: None,
2571                        checkbox: Some(context_keys::FILE_EXPLORER.to_string()),
2572                    },
2573                    MenuItem::Separator { separator: true },
2574                    MenuItem::Action {
2575                        label: t!("menu.view.line_numbers").to_string(),
2576                        action: "toggle_line_numbers".to_string(),
2577                        args: HashMap::new(),
2578                        when: None,
2579                        checkbox: Some(context_keys::LINE_NUMBERS.to_string()),
2580                    },
2581                    MenuItem::Action {
2582                        label: t!("menu.view.line_wrap").to_string(),
2583                        action: "toggle_line_wrap".to_string(),
2584                        args: HashMap::new(),
2585                        when: None,
2586                        checkbox: Some(context_keys::LINE_WRAP.to_string()),
2587                    },
2588                    MenuItem::Action {
2589                        label: t!("menu.view.mouse_support").to_string(),
2590                        action: "toggle_mouse_capture".to_string(),
2591                        args: HashMap::new(),
2592                        when: None,
2593                        checkbox: Some(context_keys::MOUSE_CAPTURE.to_string()),
2594                    },
2595                    MenuItem::Separator { separator: true },
2596                    MenuItem::Action {
2597                        label: t!("menu.view.vertical_scrollbar").to_string(),
2598                        action: "toggle_vertical_scrollbar".to_string(),
2599                        args: HashMap::new(),
2600                        when: None,
2601                        checkbox: Some(context_keys::VERTICAL_SCROLLBAR.to_string()),
2602                    },
2603                    MenuItem::Action {
2604                        label: t!("menu.view.horizontal_scrollbar").to_string(),
2605                        action: "toggle_horizontal_scrollbar".to_string(),
2606                        args: HashMap::new(),
2607                        when: None,
2608                        checkbox: Some(context_keys::HORIZONTAL_SCROLLBAR.to_string()),
2609                    },
2610                    MenuItem::Separator { separator: true },
2611                    MenuItem::Action {
2612                        label: t!("menu.view.set_background").to_string(),
2613                        action: "set_background".to_string(),
2614                        args: HashMap::new(),
2615                        when: None,
2616                        checkbox: None,
2617                    },
2618                    MenuItem::Action {
2619                        label: t!("menu.view.set_background_blend").to_string(),
2620                        action: "set_background_blend".to_string(),
2621                        args: HashMap::new(),
2622                        when: None,
2623                        checkbox: None,
2624                    },
2625                    MenuItem::Action {
2626                        label: t!("menu.view.set_page_width").to_string(),
2627                        action: "set_page_width".to_string(),
2628                        args: HashMap::new(),
2629                        when: None,
2630                        checkbox: None,
2631                    },
2632                    MenuItem::Separator { separator: true },
2633                    MenuItem::Action {
2634                        label: t!("menu.view.select_theme").to_string(),
2635                        action: "select_theme".to_string(),
2636                        args: HashMap::new(),
2637                        when: None,
2638                        checkbox: None,
2639                    },
2640                    MenuItem::Action {
2641                        label: t!("menu.view.select_locale").to_string(),
2642                        action: "select_locale".to_string(),
2643                        args: HashMap::new(),
2644                        when: None,
2645                        checkbox: None,
2646                    },
2647                    MenuItem::Action {
2648                        label: t!("menu.view.settings").to_string(),
2649                        action: "open_settings".to_string(),
2650                        args: HashMap::new(),
2651                        when: None,
2652                        checkbox: None,
2653                    },
2654                    MenuItem::Action {
2655                        label: t!("menu.view.calibrate_input").to_string(),
2656                        action: "calibrate_input".to_string(),
2657                        args: HashMap::new(),
2658                        when: None,
2659                        checkbox: None,
2660                    },
2661                    MenuItem::Separator { separator: true },
2662                    MenuItem::Action {
2663                        label: t!("menu.view.split_horizontal").to_string(),
2664                        action: "split_horizontal".to_string(),
2665                        args: HashMap::new(),
2666                        when: None,
2667                        checkbox: None,
2668                    },
2669                    MenuItem::Action {
2670                        label: t!("menu.view.split_vertical").to_string(),
2671                        action: "split_vertical".to_string(),
2672                        args: HashMap::new(),
2673                        when: None,
2674                        checkbox: None,
2675                    },
2676                    MenuItem::Action {
2677                        label: t!("menu.view.close_split").to_string(),
2678                        action: "close_split".to_string(),
2679                        args: HashMap::new(),
2680                        when: None,
2681                        checkbox: None,
2682                    },
2683                    MenuItem::Action {
2684                        label: t!("menu.view.scroll_sync").to_string(),
2685                        action: "toggle_scroll_sync".to_string(),
2686                        args: HashMap::new(),
2687                        when: Some(context_keys::HAS_SAME_BUFFER_SPLITS.to_string()),
2688                        checkbox: Some(context_keys::SCROLL_SYNC.to_string()),
2689                    },
2690                    MenuItem::Action {
2691                        label: t!("menu.view.focus_next_split").to_string(),
2692                        action: "next_split".to_string(),
2693                        args: HashMap::new(),
2694                        when: None,
2695                        checkbox: None,
2696                    },
2697                    MenuItem::Action {
2698                        label: t!("menu.view.focus_prev_split").to_string(),
2699                        action: "prev_split".to_string(),
2700                        args: HashMap::new(),
2701                        when: None,
2702                        checkbox: None,
2703                    },
2704                    MenuItem::Action {
2705                        label: t!("menu.view.toggle_maximize_split").to_string(),
2706                        action: "toggle_maximize_split".to_string(),
2707                        args: HashMap::new(),
2708                        when: None,
2709                        checkbox: None,
2710                    },
2711                    MenuItem::Separator { separator: true },
2712                    MenuItem::Submenu {
2713                        label: t!("menu.terminal").to_string(),
2714                        items: vec![
2715                            MenuItem::Action {
2716                                label: t!("menu.terminal.open").to_string(),
2717                                action: "open_terminal".to_string(),
2718                                args: HashMap::new(),
2719                                when: None,
2720                                checkbox: None,
2721                            },
2722                            MenuItem::Action {
2723                                label: t!("menu.terminal.close").to_string(),
2724                                action: "close_terminal".to_string(),
2725                                args: HashMap::new(),
2726                                when: None,
2727                                checkbox: None,
2728                            },
2729                            MenuItem::Separator { separator: true },
2730                            MenuItem::Action {
2731                                label: t!("menu.terminal.toggle_keyboard_capture").to_string(),
2732                                action: "toggle_keyboard_capture".to_string(),
2733                                args: HashMap::new(),
2734                                when: None,
2735                                checkbox: None,
2736                            },
2737                        ],
2738                    },
2739                    MenuItem::Separator { separator: true },
2740                    MenuItem::Submenu {
2741                        label: t!("menu.view.keybinding_style").to_string(),
2742                        items: vec![
2743                            MenuItem::Action {
2744                                label: t!("menu.view.keybinding_default").to_string(),
2745                                action: "switch_keybinding_map".to_string(),
2746                                args: {
2747                                    let mut map = HashMap::new();
2748                                    map.insert("map".to_string(), serde_json::json!("default"));
2749                                    map
2750                                },
2751                                when: None,
2752                                checkbox: Some(context_keys::KEYMAP_DEFAULT.to_string()),
2753                            },
2754                            MenuItem::Action {
2755                                label: t!("menu.view.keybinding_emacs").to_string(),
2756                                action: "switch_keybinding_map".to_string(),
2757                                args: {
2758                                    let mut map = HashMap::new();
2759                                    map.insert("map".to_string(), serde_json::json!("emacs"));
2760                                    map
2761                                },
2762                                when: None,
2763                                checkbox: Some(context_keys::KEYMAP_EMACS.to_string()),
2764                            },
2765                            MenuItem::Action {
2766                                label: t!("menu.view.keybinding_vscode").to_string(),
2767                                action: "switch_keybinding_map".to_string(),
2768                                args: {
2769                                    let mut map = HashMap::new();
2770                                    map.insert("map".to_string(), serde_json::json!("vscode"));
2771                                    map
2772                                },
2773                                when: None,
2774                                checkbox: Some(context_keys::KEYMAP_VSCODE.to_string()),
2775                            },
2776                            MenuItem::Action {
2777                                label: "macOS GUI (⌘)".to_string(),
2778                                action: "switch_keybinding_map".to_string(),
2779                                args: {
2780                                    let mut map = HashMap::new();
2781                                    map.insert("map".to_string(), serde_json::json!("macos-gui"));
2782                                    map
2783                                },
2784                                when: None,
2785                                checkbox: Some(context_keys::KEYMAP_MACOS_GUI.to_string()),
2786                            },
2787                        ],
2788                    },
2789                ],
2790            },
2791            // Selection menu
2792            Menu {
2793                id: Some("Selection".to_string()),
2794                label: t!("menu.selection").to_string(),
2795                when: None,
2796                items: vec![
2797                    MenuItem::Action {
2798                        label: t!("menu.selection.select_all").to_string(),
2799                        action: "select_all".to_string(),
2800                        args: HashMap::new(),
2801                        when: None,
2802                        checkbox: None,
2803                    },
2804                    MenuItem::Action {
2805                        label: t!("menu.selection.select_word").to_string(),
2806                        action: "select_word".to_string(),
2807                        args: HashMap::new(),
2808                        when: None,
2809                        checkbox: None,
2810                    },
2811                    MenuItem::Action {
2812                        label: t!("menu.selection.select_line").to_string(),
2813                        action: "select_line".to_string(),
2814                        args: HashMap::new(),
2815                        when: None,
2816                        checkbox: None,
2817                    },
2818                    MenuItem::Action {
2819                        label: t!("menu.selection.expand_selection").to_string(),
2820                        action: "expand_selection".to_string(),
2821                        args: HashMap::new(),
2822                        when: None,
2823                        checkbox: None,
2824                    },
2825                    MenuItem::Separator { separator: true },
2826                    MenuItem::Action {
2827                        label: t!("menu.selection.add_cursor_above").to_string(),
2828                        action: "add_cursor_above".to_string(),
2829                        args: HashMap::new(),
2830                        when: None,
2831                        checkbox: None,
2832                    },
2833                    MenuItem::Action {
2834                        label: t!("menu.selection.add_cursor_below").to_string(),
2835                        action: "add_cursor_below".to_string(),
2836                        args: HashMap::new(),
2837                        when: None,
2838                        checkbox: None,
2839                    },
2840                    MenuItem::Action {
2841                        label: t!("menu.selection.add_cursor_next_match").to_string(),
2842                        action: "add_cursor_next_match".to_string(),
2843                        args: HashMap::new(),
2844                        when: None,
2845                        checkbox: None,
2846                    },
2847                    MenuItem::Action {
2848                        label: t!("menu.selection.remove_secondary_cursors").to_string(),
2849                        action: "remove_secondary_cursors".to_string(),
2850                        args: HashMap::new(),
2851                        when: None,
2852                        checkbox: None,
2853                    },
2854                ],
2855            },
2856            // Go menu
2857            Menu {
2858                id: Some("Go".to_string()),
2859                label: t!("menu.go").to_string(),
2860                when: None,
2861                items: vec![
2862                    MenuItem::Action {
2863                        label: t!("menu.go.goto_line").to_string(),
2864                        action: "goto_line".to_string(),
2865                        args: HashMap::new(),
2866                        when: None,
2867                        checkbox: None,
2868                    },
2869                    MenuItem::Action {
2870                        label: t!("menu.go.goto_definition").to_string(),
2871                        action: "lsp_goto_definition".to_string(),
2872                        args: HashMap::new(),
2873                        when: None,
2874                        checkbox: None,
2875                    },
2876                    MenuItem::Action {
2877                        label: t!("menu.go.find_references").to_string(),
2878                        action: "lsp_references".to_string(),
2879                        args: HashMap::new(),
2880                        when: None,
2881                        checkbox: None,
2882                    },
2883                    MenuItem::Separator { separator: true },
2884                    MenuItem::Action {
2885                        label: t!("menu.go.next_buffer").to_string(),
2886                        action: "next_buffer".to_string(),
2887                        args: HashMap::new(),
2888                        when: None,
2889                        checkbox: None,
2890                    },
2891                    MenuItem::Action {
2892                        label: t!("menu.go.prev_buffer").to_string(),
2893                        action: "prev_buffer".to_string(),
2894                        args: HashMap::new(),
2895                        when: None,
2896                        checkbox: None,
2897                    },
2898                    MenuItem::Separator { separator: true },
2899                    MenuItem::Action {
2900                        label: t!("menu.go.command_palette").to_string(),
2901                        action: "command_palette".to_string(),
2902                        args: HashMap::new(),
2903                        when: None,
2904                        checkbox: None,
2905                    },
2906                ],
2907            },
2908            // LSP menu
2909            Menu {
2910                id: Some("LSP".to_string()),
2911                label: t!("menu.lsp").to_string(),
2912                when: None,
2913                items: vec![
2914                    MenuItem::Action {
2915                        label: t!("menu.lsp.show_hover").to_string(),
2916                        action: "lsp_hover".to_string(),
2917                        args: HashMap::new(),
2918                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2919                        checkbox: None,
2920                    },
2921                    MenuItem::Action {
2922                        label: t!("menu.lsp.goto_definition").to_string(),
2923                        action: "lsp_goto_definition".to_string(),
2924                        args: HashMap::new(),
2925                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2926                        checkbox: None,
2927                    },
2928                    MenuItem::Action {
2929                        label: t!("menu.lsp.find_references").to_string(),
2930                        action: "lsp_references".to_string(),
2931                        args: HashMap::new(),
2932                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2933                        checkbox: None,
2934                    },
2935                    MenuItem::Action {
2936                        label: t!("menu.lsp.rename_symbol").to_string(),
2937                        action: "lsp_rename".to_string(),
2938                        args: HashMap::new(),
2939                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2940                        checkbox: None,
2941                    },
2942                    MenuItem::Separator { separator: true },
2943                    MenuItem::Action {
2944                        label: t!("menu.lsp.show_completions").to_string(),
2945                        action: "lsp_completion".to_string(),
2946                        args: HashMap::new(),
2947                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2948                        checkbox: None,
2949                    },
2950                    MenuItem::Action {
2951                        label: t!("menu.lsp.show_signature").to_string(),
2952                        action: "lsp_signature_help".to_string(),
2953                        args: HashMap::new(),
2954                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2955                        checkbox: None,
2956                    },
2957                    MenuItem::Action {
2958                        label: t!("menu.lsp.code_actions").to_string(),
2959                        action: "lsp_code_actions".to_string(),
2960                        args: HashMap::new(),
2961                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2962                        checkbox: None,
2963                    },
2964                    MenuItem::Separator { separator: true },
2965                    MenuItem::Action {
2966                        label: t!("menu.lsp.toggle_inlay_hints").to_string(),
2967                        action: "toggle_inlay_hints".to_string(),
2968                        args: HashMap::new(),
2969                        when: Some(context_keys::LSP_AVAILABLE.to_string()),
2970                        checkbox: Some(context_keys::INLAY_HINTS.to_string()),
2971                    },
2972                    MenuItem::Action {
2973                        label: t!("menu.lsp.toggle_mouse_hover").to_string(),
2974                        action: "toggle_mouse_hover".to_string(),
2975                        args: HashMap::new(),
2976                        when: None,
2977                        checkbox: Some(context_keys::MOUSE_HOVER.to_string()),
2978                    },
2979                    MenuItem::Separator { separator: true },
2980                    MenuItem::Action {
2981                        label: t!("menu.lsp.show_status").to_string(),
2982                        action: "show_lsp_status".to_string(),
2983                        args: HashMap::new(),
2984                        when: None,
2985                        checkbox: None,
2986                    },
2987                    MenuItem::Action {
2988                        label: t!("menu.lsp.restart_server").to_string(),
2989                        action: "lsp_restart".to_string(),
2990                        args: HashMap::new(),
2991                        when: None,
2992                        checkbox: None,
2993                    },
2994                    MenuItem::Action {
2995                        label: t!("menu.lsp.stop_server").to_string(),
2996                        action: "lsp_stop".to_string(),
2997                        args: HashMap::new(),
2998                        when: None,
2999                        checkbox: None,
3000                    },
3001                    MenuItem::Separator { separator: true },
3002                    MenuItem::Action {
3003                        label: t!("menu.lsp.toggle_for_buffer").to_string(),
3004                        action: "lsp_toggle_for_buffer".to_string(),
3005                        args: HashMap::new(),
3006                        when: None,
3007                        checkbox: None,
3008                    },
3009                ],
3010            },
3011            // Explorer menu (only visible when file explorer is focused)
3012            Menu {
3013                id: Some("Explorer".to_string()),
3014                label: t!("menu.explorer").to_string(),
3015                when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3016                items: vec![
3017                    MenuItem::Action {
3018                        label: t!("menu.explorer.new_file").to_string(),
3019                        action: "file_explorer_new_file".to_string(),
3020                        args: HashMap::new(),
3021                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3022                        checkbox: None,
3023                    },
3024                    MenuItem::Action {
3025                        label: t!("menu.explorer.new_folder").to_string(),
3026                        action: "file_explorer_new_directory".to_string(),
3027                        args: HashMap::new(),
3028                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3029                        checkbox: None,
3030                    },
3031                    MenuItem::Separator { separator: true },
3032                    MenuItem::Action {
3033                        label: t!("menu.explorer.open").to_string(),
3034                        action: "file_explorer_open".to_string(),
3035                        args: HashMap::new(),
3036                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3037                        checkbox: None,
3038                    },
3039                    MenuItem::Action {
3040                        label: t!("menu.explorer.rename").to_string(),
3041                        action: "file_explorer_rename".to_string(),
3042                        args: HashMap::new(),
3043                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3044                        checkbox: None,
3045                    },
3046                    MenuItem::Action {
3047                        label: t!("menu.explorer.delete").to_string(),
3048                        action: "file_explorer_delete".to_string(),
3049                        args: HashMap::new(),
3050                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3051                        checkbox: None,
3052                    },
3053                    MenuItem::Separator { separator: true },
3054                    MenuItem::Action {
3055                        label: t!("menu.explorer.cut").to_string(),
3056                        action: "cut".to_string(),
3057                        args: HashMap::new(),
3058                        when: Some(context_keys::CAN_COPY.to_string()),
3059                        checkbox: None,
3060                    },
3061                    MenuItem::Action {
3062                        label: t!("menu.explorer.copy").to_string(),
3063                        action: "copy".to_string(),
3064                        args: HashMap::new(),
3065                        when: Some(context_keys::CAN_COPY.to_string()),
3066                        checkbox: None,
3067                    },
3068                    MenuItem::Action {
3069                        label: t!("menu.explorer.paste").to_string(),
3070                        action: "paste".to_string(),
3071                        args: HashMap::new(),
3072                        when: Some(context_keys::CAN_PASTE.to_string()),
3073                        checkbox: None,
3074                    },
3075                    MenuItem::Separator { separator: true },
3076                    MenuItem::Action {
3077                        label: t!("menu.explorer.refresh").to_string(),
3078                        action: "file_explorer_refresh".to_string(),
3079                        args: HashMap::new(),
3080                        when: Some(context_keys::FILE_EXPLORER_FOCUSED.to_string()),
3081                        checkbox: None,
3082                    },
3083                    MenuItem::Separator { separator: true },
3084                    MenuItem::Action {
3085                        label: t!("menu.explorer.show_hidden").to_string(),
3086                        action: "file_explorer_toggle_hidden".to_string(),
3087                        args: HashMap::new(),
3088                        when: Some(context_keys::FILE_EXPLORER.to_string()),
3089                        checkbox: Some(context_keys::FILE_EXPLORER_SHOW_HIDDEN.to_string()),
3090                    },
3091                    MenuItem::Action {
3092                        label: t!("menu.explorer.show_gitignored").to_string(),
3093                        action: "file_explorer_toggle_gitignored".to_string(),
3094                        args: HashMap::new(),
3095                        when: Some(context_keys::FILE_EXPLORER.to_string()),
3096                        checkbox: Some(context_keys::FILE_EXPLORER_SHOW_GITIGNORED.to_string()),
3097                    },
3098                ],
3099            },
3100            // Help menu
3101            Menu {
3102                id: Some("Help".to_string()),
3103                label: t!("menu.help").to_string(),
3104                when: None,
3105                items: vec![
3106                    MenuItem::Label {
3107                        info: format!("Fresh v{}", env!("CARGO_PKG_VERSION")),
3108                    },
3109                    MenuItem::Separator { separator: true },
3110                    MenuItem::Action {
3111                        label: t!("menu.help.show_manual").to_string(),
3112                        action: "show_help".to_string(),
3113                        args: HashMap::new(),
3114                        when: None,
3115                        checkbox: None,
3116                    },
3117                    MenuItem::Action {
3118                        label: t!("menu.help.keyboard_shortcuts").to_string(),
3119                        action: "keyboard_shortcuts".to_string(),
3120                        args: HashMap::new(),
3121                        when: None,
3122                        checkbox: None,
3123                    },
3124                    MenuItem::Separator { separator: true },
3125                    MenuItem::Action {
3126                        label: t!("menu.help.event_debug").to_string(),
3127                        action: "event_debug".to_string(),
3128                        args: HashMap::new(),
3129                        when: None,
3130                        checkbox: None,
3131                    },
3132                ],
3133            },
3134        ]
3135    }
3136}
3137
3138impl Config {
3139    /// The config filename used throughout the application
3140    pub(crate) const FILENAME: &'static str = "config.json";
3141
3142    /// Get the local config path (in the working directory)
3143    pub(crate) fn local_config_path(working_dir: &Path) -> std::path::PathBuf {
3144        working_dir.join(Self::FILENAME)
3145    }
3146
3147    /// Load configuration from a JSON file
3148    ///
3149    /// This deserializes the user's config file as a partial config and resolves
3150    /// it with system defaults. For HashMap fields like `lsp` and `languages`,
3151    /// entries from the user config are merged with the default entries.
3152    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
3153        let contents = std::fs::read_to_string(path.as_ref())
3154            .map_err(|e| ConfigError::IoError(e.to_string()))?;
3155
3156        // Deserialize as PartialConfig first, then resolve with defaults
3157        let partial: crate::partial_config::PartialConfig =
3158            serde_json::from_str(&contents).map_err(|e| ConfigError::ParseError(e.to_string()))?;
3159
3160        Ok(partial.resolve())
3161    }
3162
3163    /// Load a built-in keymap from embedded JSON
3164    fn load_builtin_keymap(name: &str) -> Option<KeymapConfig> {
3165        let json_content = match name {
3166            "default" => include_str!("../keymaps/default.json"),
3167            "emacs" => include_str!("../keymaps/emacs.json"),
3168            "vscode" => include_str!("../keymaps/vscode.json"),
3169            "macos" => include_str!("../keymaps/macos.json"),
3170            "macos-gui" => include_str!("../keymaps/macos-gui.json"),
3171            _ => return None,
3172        };
3173
3174        match serde_json::from_str(json_content) {
3175            Ok(config) => Some(config),
3176            Err(e) => {
3177                eprintln!("Failed to parse builtin keymap '{}': {}", name, e);
3178                None
3179            }
3180        }
3181    }
3182
3183    /// Resolve a keymap with inheritance
3184    /// Returns all bindings from the keymap and its parent chain
3185    pub fn resolve_keymap(&self, map_name: &str) -> Vec<Keybinding> {
3186        let mut visited = std::collections::HashSet::new();
3187        self.resolve_keymap_recursive(map_name, &mut visited)
3188    }
3189
3190    /// Recursive helper for resolve_keymap
3191    fn resolve_keymap_recursive(
3192        &self,
3193        map_name: &str,
3194        visited: &mut std::collections::HashSet<String>,
3195    ) -> Vec<Keybinding> {
3196        // Prevent infinite loops
3197        if visited.contains(map_name) {
3198            eprintln!(
3199                "Warning: Circular inheritance detected in keymap '{}'",
3200                map_name
3201            );
3202            return Vec::new();
3203        }
3204        visited.insert(map_name.to_string());
3205
3206        // Try to load the keymap (user-defined or built-in)
3207        let keymap = self
3208            .keybinding_maps
3209            .get(map_name)
3210            .cloned()
3211            .or_else(|| Self::load_builtin_keymap(map_name));
3212
3213        let Some(keymap) = keymap else {
3214            return Vec::new();
3215        };
3216
3217        // Start with parent bindings (if any)
3218        let mut all_bindings = if let Some(ref parent_name) = keymap.inherits {
3219            self.resolve_keymap_recursive(parent_name, visited)
3220        } else {
3221            Vec::new()
3222        };
3223
3224        // Add this keymap's bindings (they override parent bindings)
3225        all_bindings.extend(keymap.bindings);
3226
3227        all_bindings
3228    }
3229    /// Create default language configurations
3230    fn default_languages() -> HashMap<String, LanguageConfig> {
3231        let mut languages = HashMap::new();
3232
3233        languages.insert(
3234            "rust".to_string(),
3235            LanguageConfig {
3236                extensions: vec!["rs".to_string()],
3237                filenames: vec![],
3238                grammar: "rust".to_string(),
3239                comment_prefix: Some("//".to_string()),
3240                auto_indent: true,
3241                auto_close: None,
3242                auto_surround: None,
3243                textmate_grammar: None,
3244                show_whitespace_tabs: true,
3245                line_wrap: None,
3246                wrap_column: None,
3247                page_view: None,
3248                page_width: None,
3249                use_tabs: None,
3250                tab_size: None,
3251                formatter: Some(FormatterConfig {
3252                    command: "rustfmt".to_string(),
3253                    args: vec!["--edition".to_string(), "2021".to_string()],
3254                    stdin: true,
3255                    timeout_ms: 10000,
3256                }),
3257                format_on_save: false,
3258                on_save: vec![],
3259                word_characters: None,
3260            },
3261        );
3262
3263        languages.insert(
3264            "javascript".to_string(),
3265            LanguageConfig {
3266                extensions: vec!["js".to_string(), "jsx".to_string(), "mjs".to_string()],
3267                filenames: vec![],
3268                grammar: "javascript".to_string(),
3269                comment_prefix: Some("//".to_string()),
3270                auto_indent: true,
3271                auto_close: None,
3272                auto_surround: None,
3273                textmate_grammar: None,
3274                show_whitespace_tabs: true,
3275                line_wrap: None,
3276                wrap_column: None,
3277                page_view: None,
3278                page_width: None,
3279                use_tabs: None,
3280                tab_size: None,
3281                formatter: Some(FormatterConfig {
3282                    command: "prettier".to_string(),
3283                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
3284                    stdin: true,
3285                    timeout_ms: 10000,
3286                }),
3287                format_on_save: false,
3288                on_save: vec![],
3289                word_characters: None,
3290            },
3291        );
3292
3293        languages.insert(
3294            "typescript".to_string(),
3295            LanguageConfig {
3296                extensions: vec!["ts".to_string(), "tsx".to_string(), "mts".to_string()],
3297                filenames: vec![],
3298                grammar: "typescript".to_string(),
3299                comment_prefix: Some("//".to_string()),
3300                auto_indent: true,
3301                auto_close: None,
3302                auto_surround: None,
3303                textmate_grammar: None,
3304                show_whitespace_tabs: true,
3305                line_wrap: None,
3306                wrap_column: None,
3307                page_view: None,
3308                page_width: None,
3309                use_tabs: None,
3310                tab_size: None,
3311                formatter: Some(FormatterConfig {
3312                    command: "prettier".to_string(),
3313                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
3314                    stdin: true,
3315                    timeout_ms: 10000,
3316                }),
3317                format_on_save: false,
3318                on_save: vec![],
3319                word_characters: None,
3320            },
3321        );
3322
3323        languages.insert(
3324            "python".to_string(),
3325            LanguageConfig {
3326                extensions: vec!["py".to_string(), "pyi".to_string()],
3327                filenames: vec![],
3328                grammar: "python".to_string(),
3329                comment_prefix: Some("#".to_string()),
3330                auto_indent: true,
3331                auto_close: None,
3332                auto_surround: None,
3333                textmate_grammar: None,
3334                show_whitespace_tabs: true,
3335                line_wrap: None,
3336                wrap_column: None,
3337                page_view: None,
3338                page_width: None,
3339                use_tabs: None,
3340                tab_size: None,
3341                formatter: Some(FormatterConfig {
3342                    command: "ruff".to_string(),
3343                    args: vec![
3344                        "format".to_string(),
3345                        "--stdin-filename".to_string(),
3346                        "$FILE".to_string(),
3347                    ],
3348                    stdin: true,
3349                    timeout_ms: 10000,
3350                }),
3351                format_on_save: false,
3352                on_save: vec![],
3353                word_characters: None,
3354            },
3355        );
3356
3357        languages.insert(
3358            "c".to_string(),
3359            LanguageConfig {
3360                extensions: vec!["c".to_string(), "h".to_string()],
3361                filenames: vec![],
3362                grammar: "c".to_string(),
3363                comment_prefix: Some("//".to_string()),
3364                auto_indent: true,
3365                auto_close: None,
3366                auto_surround: None,
3367                textmate_grammar: None,
3368                show_whitespace_tabs: true,
3369                line_wrap: None,
3370                wrap_column: None,
3371                page_view: None,
3372                page_width: None,
3373                use_tabs: None,
3374                tab_size: None,
3375                formatter: Some(FormatterConfig {
3376                    command: "clang-format".to_string(),
3377                    args: vec![],
3378                    stdin: true,
3379                    timeout_ms: 10000,
3380                }),
3381                format_on_save: false,
3382                on_save: vec![],
3383                word_characters: None,
3384            },
3385        );
3386
3387        languages.insert(
3388            "cpp".to_string(),
3389            LanguageConfig {
3390                extensions: vec![
3391                    "cpp".to_string(),
3392                    "cc".to_string(),
3393                    "cxx".to_string(),
3394                    "hpp".to_string(),
3395                    "hh".to_string(),
3396                    "hxx".to_string(),
3397                ],
3398                filenames: vec![],
3399                grammar: "cpp".to_string(),
3400                comment_prefix: Some("//".to_string()),
3401                auto_indent: true,
3402                auto_close: None,
3403                auto_surround: None,
3404                textmate_grammar: None,
3405                show_whitespace_tabs: true,
3406                line_wrap: None,
3407                wrap_column: None,
3408                page_view: None,
3409                page_width: None,
3410                use_tabs: None,
3411                tab_size: None,
3412                formatter: Some(FormatterConfig {
3413                    command: "clang-format".to_string(),
3414                    args: vec![],
3415                    stdin: true,
3416                    timeout_ms: 10000,
3417                }),
3418                format_on_save: false,
3419                on_save: vec![],
3420                word_characters: None,
3421            },
3422        );
3423
3424        languages.insert(
3425            "csharp".to_string(),
3426            LanguageConfig {
3427                extensions: vec!["cs".to_string()],
3428                filenames: vec![],
3429                grammar: "C#".to_string(),
3430                comment_prefix: Some("//".to_string()),
3431                auto_indent: true,
3432                auto_close: None,
3433                auto_surround: None,
3434                textmate_grammar: None,
3435                show_whitespace_tabs: true,
3436                line_wrap: None,
3437                wrap_column: None,
3438                page_view: None,
3439                page_width: None,
3440                use_tabs: None,
3441                tab_size: None,
3442                formatter: None,
3443                format_on_save: false,
3444                on_save: vec![],
3445                word_characters: None,
3446            },
3447        );
3448
3449        languages.insert(
3450            "bash".to_string(),
3451            LanguageConfig {
3452                extensions: vec!["sh".to_string(), "bash".to_string()],
3453                filenames: vec![
3454                    ".bash_aliases".to_string(),
3455                    ".bash_logout".to_string(),
3456                    ".bash_profile".to_string(),
3457                    ".bashrc".to_string(),
3458                    ".env".to_string(),
3459                    ".profile".to_string(),
3460                    ".zlogin".to_string(),
3461                    ".zlogout".to_string(),
3462                    ".zprofile".to_string(),
3463                    ".zshenv".to_string(),
3464                    ".zshrc".to_string(),
3465                    // Common shell script files without extensions
3466                    "PKGBUILD".to_string(),
3467                    "APKBUILD".to_string(),
3468                ],
3469                grammar: "bash".to_string(),
3470                comment_prefix: Some("#".to_string()),
3471                auto_indent: true,
3472                auto_close: None,
3473                auto_surround: None,
3474                textmate_grammar: None,
3475                show_whitespace_tabs: true,
3476                line_wrap: None,
3477                wrap_column: None,
3478                page_view: None,
3479                page_width: None,
3480                use_tabs: None,
3481                tab_size: None,
3482                formatter: None,
3483                format_on_save: false,
3484                on_save: vec![],
3485                word_characters: None,
3486            },
3487        );
3488
3489        languages.insert(
3490            "makefile".to_string(),
3491            LanguageConfig {
3492                extensions: vec!["mk".to_string()],
3493                filenames: vec![
3494                    "Makefile".to_string(),
3495                    "makefile".to_string(),
3496                    "GNUmakefile".to_string(),
3497                ],
3498                grammar: "Makefile".to_string(),
3499                comment_prefix: Some("#".to_string()),
3500                auto_indent: false,
3501                auto_close: None,
3502                auto_surround: None,
3503                textmate_grammar: None,
3504                show_whitespace_tabs: true,
3505                line_wrap: None,
3506                wrap_column: None,
3507                page_view: None,
3508                page_width: None,
3509                use_tabs: Some(true), // Makefiles require tabs for recipes
3510                tab_size: Some(8),    // Makefiles traditionally use 8-space tabs
3511                formatter: None,
3512                format_on_save: false,
3513                on_save: vec![],
3514                word_characters: None,
3515            },
3516        );
3517
3518        languages.insert(
3519            "dockerfile".to_string(),
3520            LanguageConfig {
3521                extensions: vec!["dockerfile".to_string()],
3522                filenames: vec!["Dockerfile".to_string(), "Containerfile".to_string()],
3523                grammar: "dockerfile".to_string(),
3524                comment_prefix: Some("#".to_string()),
3525                auto_indent: true,
3526                auto_close: None,
3527                auto_surround: None,
3528                textmate_grammar: None,
3529                show_whitespace_tabs: true,
3530                line_wrap: None,
3531                wrap_column: None,
3532                page_view: None,
3533                page_width: None,
3534                use_tabs: None,
3535                tab_size: None,
3536                formatter: None,
3537                format_on_save: false,
3538                on_save: vec![],
3539                word_characters: None,
3540            },
3541        );
3542
3543        languages.insert(
3544            "json".to_string(),
3545            LanguageConfig {
3546                extensions: vec!["json".to_string()],
3547                filenames: vec![],
3548                grammar: "json".to_string(),
3549                comment_prefix: None,
3550                auto_indent: true,
3551                auto_close: None,
3552                auto_surround: None,
3553                textmate_grammar: None,
3554                show_whitespace_tabs: true,
3555                line_wrap: None,
3556                wrap_column: None,
3557                page_view: None,
3558                page_width: None,
3559                use_tabs: None,
3560                tab_size: None,
3561                formatter: Some(FormatterConfig {
3562                    command: "prettier".to_string(),
3563                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
3564                    stdin: true,
3565                    timeout_ms: 10000,
3566                }),
3567                format_on_save: false,
3568                on_save: vec![],
3569                word_characters: None,
3570            },
3571        );
3572
3573        // JSONC — JSON with Comments. Shares the tree-sitter-json parser (via
3574        // the `jsonc` grammar alias in `fresh-languages`) but keeps a separate
3575        // language id so the `vscode-json-language-server` receives the
3576        // correct `languageId` and well-known JSONC filenames like
3577        // `devcontainer.json` and `tsconfig.json` route here instead of to
3578        // strict JSON.
3579        languages.insert(
3580            "jsonc".to_string(),
3581            LanguageConfig {
3582                extensions: vec!["jsonc".to_string()],
3583                filenames: vec![
3584                    "devcontainer.json".to_string(),
3585                    ".devcontainer.json".to_string(),
3586                    "tsconfig.json".to_string(),
3587                    "tsconfig.*.json".to_string(),
3588                    "jsconfig.json".to_string(),
3589                    "jsconfig.*.json".to_string(),
3590                    ".eslintrc.json".to_string(),
3591                    ".babelrc".to_string(),
3592                    ".babelrc.json".to_string(),
3593                    ".swcrc".to_string(),
3594                    ".jshintrc".to_string(),
3595                    ".hintrc".to_string(),
3596                    "settings.json".to_string(),
3597                    "keybindings.json".to_string(),
3598                    "tasks.json".to_string(),
3599                    "launch.json".to_string(),
3600                    "extensions.json".to_string(),
3601                    "argv.json".to_string(),
3602                ],
3603                grammar: "jsonc".to_string(),
3604                comment_prefix: Some("//".to_string()),
3605                auto_indent: true,
3606                auto_close: None,
3607                auto_surround: None,
3608                textmate_grammar: None,
3609                show_whitespace_tabs: true,
3610                line_wrap: None,
3611                wrap_column: None,
3612                page_view: None,
3613                page_width: None,
3614                use_tabs: None,
3615                tab_size: None,
3616                formatter: Some(FormatterConfig {
3617                    command: "prettier".to_string(),
3618                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
3619                    stdin: true,
3620                    timeout_ms: 10000,
3621                }),
3622                format_on_save: false,
3623                on_save: vec![],
3624                word_characters: None,
3625            },
3626        );
3627
3628        languages.insert(
3629            "toml".to_string(),
3630            LanguageConfig {
3631                extensions: vec!["toml".to_string()],
3632                filenames: vec!["Cargo.lock".to_string()],
3633                grammar: "toml".to_string(),
3634                comment_prefix: Some("#".to_string()),
3635                auto_indent: true,
3636                auto_close: None,
3637                auto_surround: None,
3638                textmate_grammar: None,
3639                show_whitespace_tabs: true,
3640                line_wrap: None,
3641                wrap_column: None,
3642                page_view: None,
3643                page_width: None,
3644                use_tabs: None,
3645                tab_size: None,
3646                formatter: None,
3647                format_on_save: false,
3648                on_save: vec![],
3649                word_characters: None,
3650            },
3651        );
3652
3653        languages.insert(
3654            "yaml".to_string(),
3655            LanguageConfig {
3656                extensions: vec!["yml".to_string(), "yaml".to_string()],
3657                filenames: vec![],
3658                grammar: "yaml".to_string(),
3659                comment_prefix: Some("#".to_string()),
3660                auto_indent: true,
3661                auto_close: None,
3662                auto_surround: None,
3663                textmate_grammar: None,
3664                show_whitespace_tabs: true,
3665                line_wrap: None,
3666                wrap_column: None,
3667                page_view: None,
3668                page_width: None,
3669                use_tabs: None,
3670                tab_size: None,
3671                formatter: Some(FormatterConfig {
3672                    command: "prettier".to_string(),
3673                    args: vec!["--stdin-filepath".to_string(), "$FILE".to_string()],
3674                    stdin: true,
3675                    timeout_ms: 10000,
3676                }),
3677                format_on_save: false,
3678                on_save: vec![],
3679                word_characters: None,
3680            },
3681        );
3682
3683        languages.insert(
3684            "markdown".to_string(),
3685            LanguageConfig {
3686                extensions: vec!["md".to_string(), "markdown".to_string()],
3687                filenames: vec!["README".to_string()],
3688                grammar: "markdown".to_string(),
3689                comment_prefix: None,
3690                auto_indent: false,
3691                auto_close: None,
3692                auto_surround: None,
3693                textmate_grammar: None,
3694                show_whitespace_tabs: true,
3695                line_wrap: None,
3696                wrap_column: None,
3697                page_view: None,
3698                page_width: None,
3699                use_tabs: None,
3700                tab_size: None,
3701                formatter: None,
3702                format_on_save: false,
3703                on_save: vec![],
3704                word_characters: None,
3705            },
3706        );
3707
3708        // Go uses tabs for indentation by convention, so hide tab indicators and use tabs
3709        languages.insert(
3710            "go".to_string(),
3711            LanguageConfig {
3712                extensions: vec!["go".to_string()],
3713                filenames: vec![],
3714                grammar: "go".to_string(),
3715                comment_prefix: Some("//".to_string()),
3716                auto_indent: true,
3717                auto_close: None,
3718                auto_surround: None,
3719                textmate_grammar: None,
3720                show_whitespace_tabs: false,
3721                line_wrap: None,
3722                wrap_column: None,
3723                page_view: None,
3724                page_width: None,
3725                use_tabs: Some(true), // Go convention is to use tabs
3726                tab_size: Some(8),    // Go convention is 8-space tab width
3727                formatter: Some(FormatterConfig {
3728                    command: "gofmt".to_string(),
3729                    args: vec![],
3730                    stdin: true,
3731                    timeout_ms: 10000,
3732                }),
3733                format_on_save: false,
3734                on_save: vec![],
3735                word_characters: None,
3736            },
3737        );
3738
3739        languages.insert(
3740            "odin".to_string(),
3741            LanguageConfig {
3742                extensions: vec!["odin".to_string()],
3743                filenames: vec![],
3744                grammar: "odin".to_string(),
3745                comment_prefix: Some("//".to_string()),
3746                auto_indent: true,
3747                auto_close: None,
3748                auto_surround: None,
3749                textmate_grammar: None,
3750                show_whitespace_tabs: false,
3751                line_wrap: None,
3752                wrap_column: None,
3753                page_view: None,
3754                page_width: None,
3755                use_tabs: Some(true),
3756                tab_size: Some(8),
3757                formatter: None,
3758                format_on_save: false,
3759                on_save: vec![],
3760                word_characters: None,
3761            },
3762        );
3763
3764        languages.insert(
3765            "zig".to_string(),
3766            LanguageConfig {
3767                extensions: vec!["zig".to_string(), "zon".to_string()],
3768                filenames: vec![],
3769                grammar: "zig".to_string(),
3770                comment_prefix: Some("//".to_string()),
3771                auto_indent: true,
3772                auto_close: None,
3773                auto_surround: None,
3774                textmate_grammar: None,
3775                show_whitespace_tabs: true,
3776                line_wrap: None,
3777                wrap_column: None,
3778                page_view: None,
3779                page_width: None,
3780                use_tabs: None,
3781                tab_size: None,
3782                formatter: None,
3783                format_on_save: false,
3784                on_save: vec![],
3785                word_characters: None,
3786            },
3787        );
3788
3789        languages.insert(
3790            "java".to_string(),
3791            LanguageConfig {
3792                extensions: vec!["java".to_string()],
3793                filenames: vec![],
3794                grammar: "java".to_string(),
3795                comment_prefix: Some("//".to_string()),
3796                auto_indent: true,
3797                auto_close: None,
3798                auto_surround: None,
3799                textmate_grammar: None,
3800                show_whitespace_tabs: true,
3801                line_wrap: None,
3802                wrap_column: None,
3803                page_view: None,
3804                page_width: None,
3805                use_tabs: None,
3806                tab_size: None,
3807                formatter: None,
3808                format_on_save: false,
3809                on_save: vec![],
3810                word_characters: None,
3811            },
3812        );
3813
3814        languages.insert(
3815            "latex".to_string(),
3816            LanguageConfig {
3817                extensions: vec![
3818                    "tex".to_string(),
3819                    "latex".to_string(),
3820                    "ltx".to_string(),
3821                    "sty".to_string(),
3822                    "cls".to_string(),
3823                    "bib".to_string(),
3824                ],
3825                filenames: vec![],
3826                grammar: "latex".to_string(),
3827                comment_prefix: Some("%".to_string()),
3828                auto_indent: true,
3829                auto_close: None,
3830                auto_surround: None,
3831                textmate_grammar: None,
3832                show_whitespace_tabs: true,
3833                line_wrap: None,
3834                wrap_column: None,
3835                page_view: None,
3836                page_width: None,
3837                use_tabs: None,
3838                tab_size: None,
3839                formatter: None,
3840                format_on_save: false,
3841                on_save: vec![],
3842                word_characters: None,
3843            },
3844        );
3845
3846        languages.insert(
3847            "templ".to_string(),
3848            LanguageConfig {
3849                extensions: vec!["templ".to_string()],
3850                filenames: vec![],
3851                grammar: "go".to_string(), // Templ uses Go-like syntax
3852                comment_prefix: Some("//".to_string()),
3853                auto_indent: true,
3854                auto_close: None,
3855                auto_surround: None,
3856                textmate_grammar: None,
3857                show_whitespace_tabs: true,
3858                line_wrap: None,
3859                wrap_column: None,
3860                page_view: None,
3861                page_width: None,
3862                use_tabs: None,
3863                tab_size: None,
3864                formatter: None,
3865                format_on_save: false,
3866                on_save: vec![],
3867                word_characters: None,
3868            },
3869        );
3870
3871        // Git-related file types
3872        languages.insert(
3873            "git-rebase".to_string(),
3874            LanguageConfig {
3875                extensions: vec![],
3876                filenames: vec!["git-rebase-todo".to_string()],
3877                grammar: "Git Rebase Todo".to_string(),
3878                comment_prefix: Some("#".to_string()),
3879                auto_indent: false,
3880                auto_close: None,
3881                auto_surround: None,
3882                textmate_grammar: None,
3883                show_whitespace_tabs: true,
3884                line_wrap: None,
3885                wrap_column: None,
3886                page_view: None,
3887                page_width: None,
3888                use_tabs: None,
3889                tab_size: None,
3890                formatter: None,
3891                format_on_save: false,
3892                on_save: vec![],
3893                word_characters: None,
3894            },
3895        );
3896
3897        languages.insert(
3898            "git-commit".to_string(),
3899            LanguageConfig {
3900                extensions: vec![],
3901                filenames: vec![
3902                    "COMMIT_EDITMSG".to_string(),
3903                    "MERGE_MSG".to_string(),
3904                    "SQUASH_MSG".to_string(),
3905                    "TAG_EDITMSG".to_string(),
3906                ],
3907                grammar: "Git Commit Message".to_string(),
3908                comment_prefix: Some("#".to_string()),
3909                auto_indent: false,
3910                auto_close: None,
3911                auto_surround: None,
3912                textmate_grammar: None,
3913                show_whitespace_tabs: true,
3914                line_wrap: None,
3915                wrap_column: None,
3916                page_view: None,
3917                page_width: None,
3918                use_tabs: None,
3919                tab_size: None,
3920                formatter: None,
3921                format_on_save: false,
3922                on_save: vec![],
3923                word_characters: None,
3924            },
3925        );
3926
3927        languages.insert(
3928            "gitignore".to_string(),
3929            LanguageConfig {
3930                extensions: vec!["gitignore".to_string()],
3931                filenames: vec![
3932                    ".gitignore".to_string(),
3933                    ".dockerignore".to_string(),
3934                    ".npmignore".to_string(),
3935                    ".hgignore".to_string(),
3936                ],
3937                grammar: "Gitignore".to_string(),
3938                comment_prefix: Some("#".to_string()),
3939                auto_indent: false,
3940                auto_close: None,
3941                auto_surround: None,
3942                textmate_grammar: None,
3943                show_whitespace_tabs: true,
3944                line_wrap: None,
3945                wrap_column: None,
3946                page_view: None,
3947                page_width: None,
3948                use_tabs: None,
3949                tab_size: None,
3950                formatter: None,
3951                format_on_save: false,
3952                on_save: vec![],
3953                word_characters: None,
3954            },
3955        );
3956
3957        languages.insert(
3958            "gitconfig".to_string(),
3959            LanguageConfig {
3960                extensions: vec!["gitconfig".to_string()],
3961                filenames: vec![".gitconfig".to_string(), ".gitmodules".to_string()],
3962                grammar: "Git Config".to_string(),
3963                comment_prefix: Some("#".to_string()),
3964                auto_indent: true,
3965                auto_close: None,
3966                auto_surround: None,
3967                textmate_grammar: None,
3968                show_whitespace_tabs: true,
3969                line_wrap: None,
3970                wrap_column: None,
3971                page_view: None,
3972                page_width: None,
3973                use_tabs: None,
3974                tab_size: None,
3975                formatter: None,
3976                format_on_save: false,
3977                on_save: vec![],
3978                word_characters: None,
3979            },
3980        );
3981
3982        languages.insert(
3983            "gitattributes".to_string(),
3984            LanguageConfig {
3985                extensions: vec!["gitattributes".to_string()],
3986                filenames: vec![".gitattributes".to_string()],
3987                grammar: "Git Attributes".to_string(),
3988                comment_prefix: Some("#".to_string()),
3989                auto_indent: false,
3990                auto_close: None,
3991                auto_surround: None,
3992                textmate_grammar: None,
3993                show_whitespace_tabs: true,
3994                line_wrap: None,
3995                wrap_column: None,
3996                page_view: None,
3997                page_width: None,
3998                use_tabs: None,
3999                tab_size: None,
4000                formatter: None,
4001                format_on_save: false,
4002                on_save: vec![],
4003                word_characters: None,
4004            },
4005        );
4006
4007        languages.insert(
4008            "typst".to_string(),
4009            LanguageConfig {
4010                extensions: vec!["typ".to_string()],
4011                filenames: vec![],
4012                grammar: "Typst".to_string(),
4013                comment_prefix: Some("//".to_string()),
4014                auto_indent: true,
4015                auto_close: None,
4016                auto_surround: None,
4017                textmate_grammar: None,
4018                show_whitespace_tabs: true,
4019                line_wrap: None,
4020                wrap_column: None,
4021                page_view: None,
4022                page_width: None,
4023                use_tabs: None,
4024                tab_size: None,
4025                formatter: None,
4026                format_on_save: false,
4027                on_save: vec![],
4028                word_characters: None,
4029            },
4030        );
4031
4032        // --- Languages added for LSP support ---
4033        // These entries ensure detect_language() maps file extensions to language
4034        // names that match the LSP config keys in default_lsp_config().
4035
4036        languages.insert(
4037            "kotlin".to_string(),
4038            LanguageConfig {
4039                extensions: vec!["kt".to_string(), "kts".to_string()],
4040                filenames: vec![],
4041                grammar: "Kotlin".to_string(),
4042                comment_prefix: Some("//".to_string()),
4043                auto_indent: true,
4044                auto_close: None,
4045                auto_surround: None,
4046                textmate_grammar: None,
4047                show_whitespace_tabs: true,
4048                line_wrap: None,
4049                wrap_column: None,
4050                page_view: None,
4051                page_width: None,
4052                use_tabs: None,
4053                tab_size: None,
4054                formatter: None,
4055                format_on_save: false,
4056                on_save: vec![],
4057                word_characters: None,
4058            },
4059        );
4060
4061        languages.insert(
4062            "swift".to_string(),
4063            LanguageConfig {
4064                extensions: vec!["swift".to_string()],
4065                filenames: vec![],
4066                grammar: "Swift".to_string(),
4067                comment_prefix: Some("//".to_string()),
4068                auto_indent: true,
4069                auto_close: None,
4070                auto_surround: None,
4071                textmate_grammar: None,
4072                show_whitespace_tabs: true,
4073                line_wrap: None,
4074                wrap_column: None,
4075                page_view: None,
4076                page_width: None,
4077                use_tabs: None,
4078                tab_size: None,
4079                formatter: None,
4080                format_on_save: false,
4081                on_save: vec![],
4082                word_characters: None,
4083            },
4084        );
4085
4086        languages.insert(
4087            "scala".to_string(),
4088            LanguageConfig {
4089                extensions: vec!["scala".to_string(), "sc".to_string()],
4090                filenames: vec![],
4091                grammar: "Scala".to_string(),
4092                comment_prefix: Some("//".to_string()),
4093                auto_indent: true,
4094                auto_close: None,
4095                auto_surround: None,
4096                textmate_grammar: None,
4097                show_whitespace_tabs: true,
4098                line_wrap: None,
4099                wrap_column: None,
4100                page_view: None,
4101                page_width: None,
4102                use_tabs: None,
4103                tab_size: None,
4104                formatter: None,
4105                format_on_save: false,
4106                on_save: vec![],
4107                word_characters: None,
4108            },
4109        );
4110
4111        languages.insert(
4112            "dart".to_string(),
4113            LanguageConfig {
4114                extensions: vec!["dart".to_string()],
4115                filenames: vec![],
4116                grammar: "Dart".to_string(),
4117                comment_prefix: Some("//".to_string()),
4118                auto_indent: true,
4119                auto_close: None,
4120                auto_surround: None,
4121                textmate_grammar: None,
4122                show_whitespace_tabs: true,
4123                line_wrap: None,
4124                wrap_column: None,
4125                page_view: None,
4126                page_width: None,
4127                use_tabs: None,
4128                tab_size: None,
4129                formatter: None,
4130                format_on_save: false,
4131                on_save: vec![],
4132                word_characters: None,
4133            },
4134        );
4135
4136        languages.insert(
4137            "elixir".to_string(),
4138            LanguageConfig {
4139                extensions: vec!["ex".to_string(), "exs".to_string()],
4140                filenames: vec![],
4141                grammar: "Elixir".to_string(),
4142                comment_prefix: Some("#".to_string()),
4143                auto_indent: true,
4144                auto_close: None,
4145                auto_surround: None,
4146                textmate_grammar: None,
4147                show_whitespace_tabs: true,
4148                line_wrap: None,
4149                wrap_column: None,
4150                page_view: None,
4151                page_width: None,
4152                use_tabs: None,
4153                tab_size: None,
4154                formatter: None,
4155                format_on_save: false,
4156                on_save: vec![],
4157                word_characters: None,
4158            },
4159        );
4160
4161        languages.insert(
4162            "erlang".to_string(),
4163            LanguageConfig {
4164                extensions: vec!["erl".to_string(), "hrl".to_string()],
4165                filenames: vec![],
4166                grammar: "Erlang".to_string(),
4167                comment_prefix: Some("%".to_string()),
4168                auto_indent: true,
4169                auto_close: None,
4170                auto_surround: None,
4171                textmate_grammar: None,
4172                show_whitespace_tabs: true,
4173                line_wrap: None,
4174                wrap_column: None,
4175                page_view: None,
4176                page_width: None,
4177                use_tabs: None,
4178                tab_size: None,
4179                formatter: None,
4180                format_on_save: false,
4181                on_save: vec![],
4182                word_characters: None,
4183            },
4184        );
4185
4186        languages.insert(
4187            "haskell".to_string(),
4188            LanguageConfig {
4189                extensions: vec!["hs".to_string(), "lhs".to_string()],
4190                filenames: vec![],
4191                grammar: "Haskell".to_string(),
4192                comment_prefix: Some("--".to_string()),
4193                auto_indent: true,
4194                auto_close: None,
4195                auto_surround: None,
4196                textmate_grammar: None,
4197                show_whitespace_tabs: true,
4198                line_wrap: None,
4199                wrap_column: None,
4200                page_view: None,
4201                page_width: None,
4202                use_tabs: None,
4203                tab_size: None,
4204                formatter: None,
4205                format_on_save: false,
4206                on_save: vec![],
4207                word_characters: None,
4208            },
4209        );
4210
4211        languages.insert(
4212            "ocaml".to_string(),
4213            LanguageConfig {
4214                extensions: vec!["ml".to_string(), "mli".to_string()],
4215                filenames: vec![],
4216                grammar: "OCaml".to_string(),
4217                comment_prefix: None,
4218                auto_indent: true,
4219                auto_close: None,
4220                auto_surround: None,
4221                textmate_grammar: None,
4222                show_whitespace_tabs: true,
4223                line_wrap: None,
4224                wrap_column: None,
4225                page_view: None,
4226                page_width: None,
4227                use_tabs: None,
4228                tab_size: None,
4229                formatter: None,
4230                format_on_save: false,
4231                on_save: vec![],
4232                word_characters: None,
4233            },
4234        );
4235
4236        languages.insert(
4237            "clojure".to_string(),
4238            LanguageConfig {
4239                extensions: vec![
4240                    "clj".to_string(),
4241                    "cljs".to_string(),
4242                    "cljc".to_string(),
4243                    "edn".to_string(),
4244                ],
4245                filenames: vec![],
4246                grammar: "Clojure".to_string(),
4247                comment_prefix: Some(";".to_string()),
4248                auto_indent: true,
4249                auto_close: None,
4250                auto_surround: None,
4251                textmate_grammar: None,
4252                show_whitespace_tabs: true,
4253                line_wrap: None,
4254                wrap_column: None,
4255                page_view: None,
4256                page_width: None,
4257                use_tabs: None,
4258                tab_size: None,
4259                formatter: None,
4260                format_on_save: false,
4261                on_save: vec![],
4262                word_characters: None,
4263            },
4264        );
4265
4266        languages.insert(
4267            "r".to_string(),
4268            LanguageConfig {
4269                extensions: vec!["r".to_string(), "R".to_string(), "rmd".to_string()],
4270                filenames: vec![],
4271                grammar: "R".to_string(),
4272                comment_prefix: Some("#".to_string()),
4273                auto_indent: true,
4274                auto_close: None,
4275                auto_surround: None,
4276                textmate_grammar: None,
4277                show_whitespace_tabs: true,
4278                line_wrap: None,
4279                wrap_column: None,
4280                page_view: None,
4281                page_width: None,
4282                use_tabs: None,
4283                tab_size: None,
4284                formatter: None,
4285                format_on_save: false,
4286                on_save: vec![],
4287                word_characters: None,
4288            },
4289        );
4290
4291        languages.insert(
4292            "julia".to_string(),
4293            LanguageConfig {
4294                extensions: vec!["jl".to_string()],
4295                filenames: vec![],
4296                grammar: "Julia".to_string(),
4297                comment_prefix: Some("#".to_string()),
4298                auto_indent: true,
4299                auto_close: None,
4300                auto_surround: None,
4301                textmate_grammar: None,
4302                show_whitespace_tabs: true,
4303                line_wrap: None,
4304                wrap_column: None,
4305                page_view: None,
4306                page_width: None,
4307                use_tabs: None,
4308                tab_size: None,
4309                formatter: None,
4310                format_on_save: false,
4311                on_save: vec![],
4312                word_characters: None,
4313            },
4314        );
4315
4316        languages.insert(
4317            "perl".to_string(),
4318            LanguageConfig {
4319                extensions: vec!["pl".to_string(), "pm".to_string(), "t".to_string()],
4320                filenames: vec![],
4321                grammar: "Perl".to_string(),
4322                comment_prefix: Some("#".to_string()),
4323                auto_indent: true,
4324                auto_close: None,
4325                auto_surround: None,
4326                textmate_grammar: None,
4327                show_whitespace_tabs: true,
4328                line_wrap: None,
4329                wrap_column: None,
4330                page_view: None,
4331                page_width: None,
4332                use_tabs: None,
4333                tab_size: None,
4334                formatter: None,
4335                format_on_save: false,
4336                on_save: vec![],
4337                word_characters: None,
4338            },
4339        );
4340
4341        languages.insert(
4342            "nim".to_string(),
4343            LanguageConfig {
4344                extensions: vec!["nim".to_string(), "nims".to_string(), "nimble".to_string()],
4345                filenames: vec![],
4346                grammar: "Nim".to_string(),
4347                comment_prefix: Some("#".to_string()),
4348                auto_indent: true,
4349                auto_close: None,
4350                auto_surround: None,
4351                textmate_grammar: None,
4352                show_whitespace_tabs: true,
4353                line_wrap: None,
4354                wrap_column: None,
4355                page_view: None,
4356                page_width: None,
4357                use_tabs: None,
4358                tab_size: None,
4359                formatter: None,
4360                format_on_save: false,
4361                on_save: vec![],
4362                word_characters: None,
4363            },
4364        );
4365
4366        languages.insert(
4367            "gleam".to_string(),
4368            LanguageConfig {
4369                extensions: vec!["gleam".to_string()],
4370                filenames: vec![],
4371                grammar: "Gleam".to_string(),
4372                comment_prefix: Some("//".to_string()),
4373                auto_indent: true,
4374                auto_close: None,
4375                auto_surround: None,
4376                textmate_grammar: None,
4377                show_whitespace_tabs: true,
4378                line_wrap: None,
4379                wrap_column: None,
4380                page_view: None,
4381                page_width: None,
4382                use_tabs: None,
4383                tab_size: None,
4384                formatter: None,
4385                format_on_save: false,
4386                on_save: vec![],
4387                word_characters: None,
4388            },
4389        );
4390
4391        languages.insert(
4392            "racket".to_string(),
4393            LanguageConfig {
4394                extensions: vec![
4395                    "rkt".to_string(),
4396                    "rktd".to_string(),
4397                    "rktl".to_string(),
4398                    "scrbl".to_string(),
4399                ],
4400                filenames: vec![],
4401                grammar: "Racket".to_string(),
4402                comment_prefix: Some(";".to_string()),
4403                auto_indent: true,
4404                auto_close: None,
4405                auto_surround: None,
4406                textmate_grammar: None,
4407                show_whitespace_tabs: true,
4408                line_wrap: None,
4409                wrap_column: None,
4410                page_view: None,
4411                page_width: None,
4412                use_tabs: None,
4413                tab_size: None,
4414                formatter: None,
4415                format_on_save: false,
4416                on_save: vec![],
4417                word_characters: None,
4418            },
4419        );
4420
4421        languages.insert(
4422            "fsharp".to_string(),
4423            LanguageConfig {
4424                extensions: vec!["fs".to_string(), "fsi".to_string(), "fsx".to_string()],
4425                filenames: vec![],
4426                grammar: "FSharp".to_string(),
4427                comment_prefix: Some("//".to_string()),
4428                auto_indent: true,
4429                auto_close: None,
4430                auto_surround: None,
4431                textmate_grammar: None,
4432                show_whitespace_tabs: true,
4433                line_wrap: None,
4434                wrap_column: None,
4435                page_view: None,
4436                page_width: None,
4437                use_tabs: None,
4438                tab_size: None,
4439                formatter: None,
4440                format_on_save: false,
4441                on_save: vec![],
4442                word_characters: None,
4443            },
4444        );
4445
4446        languages.insert(
4447            "nix".to_string(),
4448            LanguageConfig {
4449                extensions: vec!["nix".to_string()],
4450                filenames: vec![],
4451                grammar: "Nix".to_string(),
4452                comment_prefix: Some("#".to_string()),
4453                auto_indent: true,
4454                auto_close: None,
4455                auto_surround: None,
4456                textmate_grammar: None,
4457                show_whitespace_tabs: true,
4458                line_wrap: None,
4459                wrap_column: None,
4460                page_view: None,
4461                page_width: None,
4462                use_tabs: None,
4463                tab_size: None,
4464                formatter: None,
4465                format_on_save: false,
4466                on_save: vec![],
4467                word_characters: None,
4468            },
4469        );
4470
4471        languages.insert(
4472            "nushell".to_string(),
4473            LanguageConfig {
4474                extensions: vec!["nu".to_string()],
4475                filenames: vec![],
4476                grammar: "Nushell".to_string(),
4477                comment_prefix: Some("#".to_string()),
4478                auto_indent: true,
4479                auto_close: None,
4480                auto_surround: None,
4481                textmate_grammar: None,
4482                show_whitespace_tabs: true,
4483                line_wrap: None,
4484                wrap_column: None,
4485                page_view: None,
4486                page_width: None,
4487                use_tabs: None,
4488                tab_size: None,
4489                formatter: None,
4490                format_on_save: false,
4491                on_save: vec![],
4492                word_characters: None,
4493            },
4494        );
4495
4496        languages.insert(
4497            "solidity".to_string(),
4498            LanguageConfig {
4499                extensions: vec!["sol".to_string()],
4500                filenames: vec![],
4501                grammar: "Solidity".to_string(),
4502                comment_prefix: Some("//".to_string()),
4503                auto_indent: true,
4504                auto_close: None,
4505                auto_surround: None,
4506                textmate_grammar: None,
4507                show_whitespace_tabs: true,
4508                line_wrap: None,
4509                wrap_column: None,
4510                page_view: None,
4511                page_width: None,
4512                use_tabs: None,
4513                tab_size: None,
4514                formatter: None,
4515                format_on_save: false,
4516                on_save: vec![],
4517                word_characters: None,
4518            },
4519        );
4520
4521        languages.insert(
4522            "ruby".to_string(),
4523            LanguageConfig {
4524                extensions: vec!["rb".to_string(), "rake".to_string(), "gemspec".to_string()],
4525                filenames: vec![
4526                    "Gemfile".to_string(),
4527                    "Rakefile".to_string(),
4528                    "Guardfile".to_string(),
4529                ],
4530                grammar: "Ruby".to_string(),
4531                comment_prefix: Some("#".to_string()),
4532                auto_indent: true,
4533                auto_close: None,
4534                auto_surround: None,
4535                textmate_grammar: None,
4536                show_whitespace_tabs: true,
4537                line_wrap: None,
4538                wrap_column: None,
4539                page_view: None,
4540                page_width: None,
4541                use_tabs: None,
4542                tab_size: None,
4543                formatter: None,
4544                format_on_save: false,
4545                on_save: vec![],
4546                word_characters: None,
4547            },
4548        );
4549
4550        languages.insert(
4551            "php".to_string(),
4552            LanguageConfig {
4553                extensions: vec!["php".to_string(), "phtml".to_string()],
4554                filenames: vec![],
4555                grammar: "PHP".to_string(),
4556                comment_prefix: Some("//".to_string()),
4557                auto_indent: true,
4558                auto_close: None,
4559                auto_surround: None,
4560                textmate_grammar: None,
4561                show_whitespace_tabs: true,
4562                line_wrap: None,
4563                wrap_column: None,
4564                page_view: None,
4565                page_width: None,
4566                use_tabs: None,
4567                tab_size: None,
4568                formatter: None,
4569                format_on_save: false,
4570                on_save: vec![],
4571                word_characters: None,
4572            },
4573        );
4574
4575        languages.insert(
4576            "lua".to_string(),
4577            LanguageConfig {
4578                extensions: vec!["lua".to_string()],
4579                filenames: vec![],
4580                grammar: "Lua".to_string(),
4581                comment_prefix: Some("--".to_string()),
4582                auto_indent: true,
4583                auto_close: None,
4584                auto_surround: None,
4585                textmate_grammar: None,
4586                show_whitespace_tabs: true,
4587                line_wrap: None,
4588                wrap_column: None,
4589                page_view: None,
4590                page_width: None,
4591                use_tabs: None,
4592                tab_size: None,
4593                formatter: None,
4594                format_on_save: false,
4595                on_save: vec![],
4596                word_characters: None,
4597            },
4598        );
4599
4600        languages.insert(
4601            "html".to_string(),
4602            LanguageConfig {
4603                extensions: vec!["html".to_string(), "htm".to_string()],
4604                filenames: vec![],
4605                grammar: "HTML".to_string(),
4606                comment_prefix: None,
4607                auto_indent: true,
4608                auto_close: None,
4609                auto_surround: None,
4610                textmate_grammar: None,
4611                show_whitespace_tabs: true,
4612                line_wrap: None,
4613                wrap_column: None,
4614                page_view: None,
4615                page_width: None,
4616                use_tabs: None,
4617                tab_size: None,
4618                formatter: None,
4619                format_on_save: false,
4620                on_save: vec![],
4621                word_characters: None,
4622            },
4623        );
4624
4625        languages.insert(
4626            "css".to_string(),
4627            LanguageConfig {
4628                extensions: vec!["css".to_string()],
4629                filenames: vec![],
4630                grammar: "CSS".to_string(),
4631                comment_prefix: None,
4632                auto_indent: true,
4633                auto_close: None,
4634                auto_surround: None,
4635                textmate_grammar: None,
4636                show_whitespace_tabs: true,
4637                line_wrap: None,
4638                wrap_column: None,
4639                page_view: None,
4640                page_width: None,
4641                use_tabs: None,
4642                tab_size: None,
4643                formatter: None,
4644                format_on_save: false,
4645                on_save: vec![],
4646                word_characters: None,
4647            },
4648        );
4649
4650        languages.insert(
4651            "sql".to_string(),
4652            LanguageConfig {
4653                extensions: vec!["sql".to_string()],
4654                filenames: vec![],
4655                grammar: "SQL".to_string(),
4656                comment_prefix: Some("--".to_string()),
4657                auto_indent: true,
4658                auto_close: None,
4659                auto_surround: None,
4660                textmate_grammar: None,
4661                show_whitespace_tabs: true,
4662                line_wrap: None,
4663                wrap_column: None,
4664                page_view: None,
4665                page_width: None,
4666                use_tabs: None,
4667                tab_size: None,
4668                formatter: None,
4669                format_on_save: false,
4670                on_save: vec![],
4671                word_characters: None,
4672            },
4673        );
4674
4675        languages.insert(
4676            "graphql".to_string(),
4677            LanguageConfig {
4678                extensions: vec!["graphql".to_string(), "gql".to_string()],
4679                filenames: vec![],
4680                grammar: "GraphQL".to_string(),
4681                comment_prefix: Some("#".to_string()),
4682                auto_indent: true,
4683                auto_close: None,
4684                auto_surround: None,
4685                textmate_grammar: None,
4686                show_whitespace_tabs: true,
4687                line_wrap: None,
4688                wrap_column: None,
4689                page_view: None,
4690                page_width: None,
4691                use_tabs: None,
4692                tab_size: None,
4693                formatter: None,
4694                format_on_save: false,
4695                on_save: vec![],
4696                word_characters: None,
4697            },
4698        );
4699
4700        languages.insert(
4701            "protobuf".to_string(),
4702            LanguageConfig {
4703                extensions: vec!["proto".to_string()],
4704                filenames: vec![],
4705                grammar: "Protocol Buffers".to_string(),
4706                comment_prefix: Some("//".to_string()),
4707                auto_indent: true,
4708                auto_close: None,
4709                auto_surround: None,
4710                textmate_grammar: None,
4711                show_whitespace_tabs: true,
4712                line_wrap: None,
4713                wrap_column: None,
4714                page_view: None,
4715                page_width: None,
4716                use_tabs: None,
4717                tab_size: None,
4718                formatter: None,
4719                format_on_save: false,
4720                on_save: vec![],
4721                word_characters: None,
4722            },
4723        );
4724
4725        languages.insert(
4726            "cmake".to_string(),
4727            LanguageConfig {
4728                extensions: vec!["cmake".to_string()],
4729                filenames: vec!["CMakeLists.txt".to_string()],
4730                grammar: "CMake".to_string(),
4731                comment_prefix: Some("#".to_string()),
4732                auto_indent: true,
4733                auto_close: None,
4734                auto_surround: None,
4735                textmate_grammar: None,
4736                show_whitespace_tabs: true,
4737                line_wrap: None,
4738                wrap_column: None,
4739                page_view: None,
4740                page_width: None,
4741                use_tabs: None,
4742                tab_size: None,
4743                formatter: None,
4744                format_on_save: false,
4745                on_save: vec![],
4746                word_characters: None,
4747            },
4748        );
4749
4750        languages.insert(
4751            "terraform".to_string(),
4752            LanguageConfig {
4753                extensions: vec!["tf".to_string(), "tfvars".to_string(), "hcl".to_string()],
4754                filenames: vec![],
4755                grammar: "HCL".to_string(),
4756                comment_prefix: Some("#".to_string()),
4757                auto_indent: true,
4758                auto_close: None,
4759                auto_surround: None,
4760                textmate_grammar: None,
4761                show_whitespace_tabs: true,
4762                line_wrap: None,
4763                wrap_column: None,
4764                page_view: None,
4765                page_width: None,
4766                use_tabs: None,
4767                tab_size: None,
4768                formatter: None,
4769                format_on_save: false,
4770                on_save: vec![],
4771                word_characters: None,
4772            },
4773        );
4774
4775        languages.insert(
4776            "vue".to_string(),
4777            LanguageConfig {
4778                extensions: vec!["vue".to_string()],
4779                filenames: vec![],
4780                grammar: "Vue".to_string(),
4781                comment_prefix: None,
4782                auto_indent: true,
4783                auto_close: None,
4784                auto_surround: None,
4785                textmate_grammar: None,
4786                show_whitespace_tabs: true,
4787                line_wrap: None,
4788                wrap_column: None,
4789                page_view: None,
4790                page_width: None,
4791                use_tabs: None,
4792                tab_size: None,
4793                formatter: None,
4794                format_on_save: false,
4795                on_save: vec![],
4796                word_characters: None,
4797            },
4798        );
4799
4800        languages.insert(
4801            "svelte".to_string(),
4802            LanguageConfig {
4803                extensions: vec!["svelte".to_string()],
4804                filenames: vec![],
4805                grammar: "Svelte".to_string(),
4806                comment_prefix: None,
4807                auto_indent: true,
4808                auto_close: None,
4809                auto_surround: None,
4810                textmate_grammar: None,
4811                show_whitespace_tabs: true,
4812                line_wrap: None,
4813                wrap_column: None,
4814                page_view: None,
4815                page_width: None,
4816                use_tabs: None,
4817                tab_size: None,
4818                formatter: None,
4819                format_on_save: false,
4820                on_save: vec![],
4821                word_characters: None,
4822            },
4823        );
4824
4825        languages.insert(
4826            "astro".to_string(),
4827            LanguageConfig {
4828                extensions: vec!["astro".to_string()],
4829                filenames: vec![],
4830                grammar: "Astro".to_string(),
4831                comment_prefix: None,
4832                auto_indent: true,
4833                auto_close: None,
4834                auto_surround: None,
4835                textmate_grammar: None,
4836                show_whitespace_tabs: true,
4837                line_wrap: None,
4838                wrap_column: None,
4839                page_view: None,
4840                page_width: None,
4841                use_tabs: None,
4842                tab_size: None,
4843                formatter: None,
4844                format_on_save: false,
4845                on_save: vec![],
4846                word_characters: None,
4847            },
4848        );
4849
4850        // --- Languages for embedded grammars (syntax highlighting only) ---
4851
4852        languages.insert(
4853            "scss".to_string(),
4854            LanguageConfig {
4855                extensions: vec!["scss".to_string()],
4856                filenames: vec![],
4857                grammar: "SCSS".to_string(),
4858                comment_prefix: Some("//".to_string()),
4859                auto_indent: true,
4860                auto_close: None,
4861                auto_surround: None,
4862                textmate_grammar: None,
4863                show_whitespace_tabs: true,
4864                line_wrap: None,
4865                wrap_column: None,
4866                page_view: None,
4867                page_width: None,
4868                use_tabs: None,
4869                tab_size: None,
4870                formatter: None,
4871                format_on_save: false,
4872                on_save: vec![],
4873                word_characters: None,
4874            },
4875        );
4876
4877        languages.insert(
4878            "less".to_string(),
4879            LanguageConfig {
4880                extensions: vec!["less".to_string()],
4881                filenames: vec![],
4882                grammar: "LESS".to_string(),
4883                comment_prefix: Some("//".to_string()),
4884                auto_indent: true,
4885                auto_close: None,
4886                auto_surround: None,
4887                textmate_grammar: None,
4888                show_whitespace_tabs: true,
4889                line_wrap: None,
4890                wrap_column: None,
4891                page_view: None,
4892                page_width: None,
4893                use_tabs: None,
4894                tab_size: None,
4895                formatter: None,
4896                format_on_save: false,
4897                on_save: vec![],
4898                word_characters: None,
4899            },
4900        );
4901
4902        languages.insert(
4903            "powershell".to_string(),
4904            LanguageConfig {
4905                extensions: vec!["ps1".to_string(), "psm1".to_string(), "psd1".to_string()],
4906                filenames: vec![],
4907                grammar: "PowerShell".to_string(),
4908                comment_prefix: Some("#".to_string()),
4909                auto_indent: true,
4910                auto_close: None,
4911                auto_surround: None,
4912                textmate_grammar: None,
4913                show_whitespace_tabs: true,
4914                line_wrap: None,
4915                wrap_column: None,
4916                page_view: None,
4917                page_width: None,
4918                use_tabs: None,
4919                tab_size: None,
4920                formatter: None,
4921                format_on_save: false,
4922                on_save: vec![],
4923                word_characters: None,
4924            },
4925        );
4926
4927        languages.insert(
4928            "kdl".to_string(),
4929            LanguageConfig {
4930                extensions: vec!["kdl".to_string()],
4931                filenames: vec![],
4932                grammar: "KDL".to_string(),
4933                comment_prefix: Some("//".to_string()),
4934                auto_indent: true,
4935                auto_close: None,
4936                auto_surround: None,
4937                textmate_grammar: None,
4938                show_whitespace_tabs: true,
4939                line_wrap: None,
4940                wrap_column: None,
4941                page_view: None,
4942                page_width: None,
4943                use_tabs: None,
4944                tab_size: None,
4945                formatter: None,
4946                format_on_save: false,
4947                on_save: vec![],
4948                word_characters: None,
4949            },
4950        );
4951
4952        languages.insert(
4953            "starlark".to_string(),
4954            LanguageConfig {
4955                extensions: vec!["bzl".to_string(), "star".to_string()],
4956                filenames: vec!["BUILD".to_string(), "WORKSPACE".to_string()],
4957                grammar: "Starlark".to_string(),
4958                comment_prefix: Some("#".to_string()),
4959                auto_indent: true,
4960                auto_close: None,
4961                auto_surround: None,
4962                textmate_grammar: None,
4963                show_whitespace_tabs: true,
4964                line_wrap: None,
4965                wrap_column: None,
4966                page_view: None,
4967                page_width: None,
4968                use_tabs: None,
4969                tab_size: None,
4970                formatter: None,
4971                format_on_save: false,
4972                on_save: vec![],
4973                word_characters: None,
4974            },
4975        );
4976
4977        languages.insert(
4978            "justfile".to_string(),
4979            LanguageConfig {
4980                extensions: vec![],
4981                filenames: vec![
4982                    "justfile".to_string(),
4983                    "Justfile".to_string(),
4984                    ".justfile".to_string(),
4985                ],
4986                grammar: "Justfile".to_string(),
4987                comment_prefix: Some("#".to_string()),
4988                auto_indent: true,
4989                auto_close: None,
4990                auto_surround: None,
4991                textmate_grammar: None,
4992                show_whitespace_tabs: true,
4993                line_wrap: None,
4994                wrap_column: None,
4995                page_view: None,
4996                page_width: None,
4997                use_tabs: Some(true),
4998                tab_size: None,
4999                formatter: None,
5000                format_on_save: false,
5001                on_save: vec![],
5002                word_characters: None,
5003            },
5004        );
5005
5006        languages.insert(
5007            "earthfile".to_string(),
5008            LanguageConfig {
5009                extensions: vec!["earth".to_string()],
5010                filenames: vec!["Earthfile".to_string()],
5011                grammar: "Earthfile".to_string(),
5012                comment_prefix: Some("#".to_string()),
5013                auto_indent: true,
5014                auto_close: None,
5015                auto_surround: None,
5016                textmate_grammar: None,
5017                show_whitespace_tabs: true,
5018                line_wrap: None,
5019                wrap_column: None,
5020                page_view: None,
5021                page_width: None,
5022                use_tabs: None,
5023                tab_size: None,
5024                formatter: None,
5025                format_on_save: false,
5026                on_save: vec![],
5027                word_characters: None,
5028            },
5029        );
5030
5031        languages.insert(
5032            "gomod".to_string(),
5033            LanguageConfig {
5034                extensions: vec![],
5035                filenames: vec!["go.mod".to_string(), "go.sum".to_string()],
5036                grammar: "Go Module".to_string(),
5037                comment_prefix: Some("//".to_string()),
5038                auto_indent: true,
5039                auto_close: None,
5040                auto_surround: None,
5041                textmate_grammar: None,
5042                show_whitespace_tabs: true,
5043                line_wrap: None,
5044                wrap_column: None,
5045                page_view: None,
5046                page_width: None,
5047                use_tabs: Some(true),
5048                tab_size: None,
5049                formatter: None,
5050                format_on_save: false,
5051                on_save: vec![],
5052                word_characters: None,
5053            },
5054        );
5055
5056        languages.insert(
5057            "vlang".to_string(),
5058            LanguageConfig {
5059                extensions: vec!["v".to_string(), "vv".to_string()],
5060                filenames: vec![],
5061                grammar: "V".to_string(),
5062                comment_prefix: Some("//".to_string()),
5063                auto_indent: true,
5064                auto_close: None,
5065                auto_surround: None,
5066                textmate_grammar: None,
5067                show_whitespace_tabs: true,
5068                line_wrap: None,
5069                wrap_column: None,
5070                page_view: None,
5071                page_width: None,
5072                use_tabs: None,
5073                tab_size: None,
5074                formatter: None,
5075                format_on_save: false,
5076                on_save: vec![],
5077                word_characters: None,
5078            },
5079        );
5080
5081        languages.insert(
5082            "ini".to_string(),
5083            LanguageConfig {
5084                extensions: vec!["ini".to_string(), "cfg".to_string()],
5085                filenames: vec![],
5086                grammar: "INI".to_string(),
5087                comment_prefix: Some(";".to_string()),
5088                auto_indent: false,
5089                auto_close: None,
5090                auto_surround: None,
5091                textmate_grammar: None,
5092                show_whitespace_tabs: true,
5093                line_wrap: None,
5094                wrap_column: None,
5095                page_view: None,
5096                page_width: None,
5097                use_tabs: None,
5098                tab_size: None,
5099                formatter: None,
5100                format_on_save: false,
5101                on_save: vec![],
5102                word_characters: None,
5103            },
5104        );
5105
5106        languages.insert(
5107            "hyprlang".to_string(),
5108            LanguageConfig {
5109                extensions: vec!["hl".to_string()],
5110                filenames: vec!["hyprland.conf".to_string()],
5111                grammar: "Hyprlang".to_string(),
5112                comment_prefix: Some("#".to_string()),
5113                auto_indent: true,
5114                auto_close: None,
5115                auto_surround: None,
5116                textmate_grammar: None,
5117                show_whitespace_tabs: true,
5118                line_wrap: None,
5119                wrap_column: None,
5120                page_view: None,
5121                page_width: None,
5122                use_tabs: None,
5123                tab_size: None,
5124                formatter: None,
5125                format_on_save: false,
5126                on_save: vec![],
5127                word_characters: None,
5128            },
5129        );
5130
5131        languages
5132    }
5133
5134    /// Create default LSP configurations
5135    #[cfg(feature = "runtime")]
5136    fn default_lsp_config() -> HashMap<String, LspLanguageConfig> {
5137        let mut lsp = HashMap::new();
5138
5139        // rust-analyzer (installed via rustup or package manager)
5140        // Enable logging to help debug LSP issues (stored in XDG state directory)
5141        let ra_log_path = crate::services::log_dirs::lsp_log_path("rust-analyzer")
5142            .to_string_lossy()
5143            .to_string();
5144
5145        Self::populate_lsp_config(&mut lsp, ra_log_path);
5146        lsp
5147    }
5148
5149    /// Create empty LSP configurations for WASM builds
5150    #[cfg(not(feature = "runtime"))]
5151    fn default_lsp_config() -> HashMap<String, LspLanguageConfig> {
5152        // LSP is not available in WASM builds
5153        HashMap::new()
5154    }
5155
5156    /// Create default universal LSP configurations (servers that apply to all languages)
5157    #[cfg(feature = "runtime")]
5158    fn default_universal_lsp_config() -> HashMap<String, LspLanguageConfig> {
5159        let mut universal = HashMap::new();
5160
5161        // quicklsp: our built-in universal LSP server.
5162        // Provides fast cross-language hover, signature help, go-to-definition,
5163        // completions, and workspace symbols with doc extraction and dependency
5164        // source indexing. Designed as a lightweight complement to heavyweight
5165        // language-specific servers.
5166        //
5167        // Disabled by default — enable via config or command palette after
5168        // installing: `cargo install --path crates/quicklsp`
5169        //
5170        // `only_features` is left unset so quicklsp can serve every feature it
5171        // advertises via its server capabilities (including go-to-definition).
5172        // Users who want to scope it down can set `only_features` explicitly.
5173        universal.insert(
5174            "quicklsp".to_string(),
5175            LspLanguageConfig::Multi(vec![LspServerConfig {
5176                command: "quicklsp".to_string(),
5177                args: vec![],
5178                enabled: false,
5179                auto_start: false,
5180                process_limits: ProcessLimits::default(),
5181                initialization_options: None,
5182                env: Default::default(),
5183                language_id_overrides: Default::default(),
5184                name: Some("QuickLSP".to_string()),
5185                only_features: None,
5186                except_features: None,
5187                root_markers: vec![
5188                    "Cargo.toml".to_string(),
5189                    "package.json".to_string(),
5190                    "go.mod".to_string(),
5191                    "pyproject.toml".to_string(),
5192                    "requirements.txt".to_string(),
5193                    ".git".to_string(),
5194                ],
5195            }]),
5196        );
5197
5198        universal
5199    }
5200
5201    /// Create empty universal LSP configurations for WASM builds
5202    #[cfg(not(feature = "runtime"))]
5203    fn default_universal_lsp_config() -> HashMap<String, LspLanguageConfig> {
5204        HashMap::new()
5205    }
5206
5207    #[cfg(feature = "runtime")]
5208    fn populate_lsp_config(lsp: &mut HashMap<String, LspLanguageConfig>, ra_log_path: String) {
5209        // rust-analyzer: full mode by default (no init param restrictions, no process limits).
5210        // Users can switch to reduced-memory mode via the "Rust LSP: Reduced Memory Mode"
5211        // command palette command (provided by the rust-lsp plugin).
5212        lsp.insert(
5213            "rust".to_string(),
5214            LspLanguageConfig::Multi(vec![LspServerConfig {
5215                command: "rust-analyzer".to_string(),
5216                args: vec!["--log-file".to_string(), ra_log_path],
5217                enabled: true,
5218                auto_start: false,
5219                process_limits: ProcessLimits::unlimited(),
5220                initialization_options: None,
5221                env: Default::default(),
5222                language_id_overrides: Default::default(),
5223                name: None,
5224                only_features: None,
5225                except_features: None,
5226                root_markers: vec![
5227                    "Cargo.toml".to_string(),
5228                    "rust-project.json".to_string(),
5229                    ".git".to_string(),
5230                ],
5231            }]),
5232        );
5233
5234        // pylsp (installed via pip)
5235        lsp.insert(
5236            "python".to_string(),
5237            LspLanguageConfig::Multi(vec![LspServerConfig {
5238                command: "pylsp".to_string(),
5239                args: vec![],
5240                enabled: true,
5241                auto_start: false,
5242                process_limits: ProcessLimits::default(),
5243                initialization_options: None,
5244                env: Default::default(),
5245                language_id_overrides: Default::default(),
5246                name: None,
5247                only_features: None,
5248                except_features: None,
5249                root_markers: vec![
5250                    "pyproject.toml".to_string(),
5251                    "setup.py".to_string(),
5252                    "setup.cfg".to_string(),
5253                    "pyrightconfig.json".to_string(),
5254                    ".git".to_string(),
5255                ],
5256            }]),
5257        );
5258
5259        // typescript-language-server (installed via npm)
5260        // Alternative: use "deno lsp" with initialization_options: {"enable": true}
5261        lsp.insert(
5262            "javascript".to_string(),
5263            LspLanguageConfig::Multi(vec![LspServerConfig {
5264                command: "typescript-language-server".to_string(),
5265                args: vec!["--stdio".to_string()],
5266                enabled: true,
5267                auto_start: false,
5268                process_limits: ProcessLimits::default(),
5269                initialization_options: None,
5270                env: Default::default(),
5271                language_id_overrides: HashMap::from([(
5272                    "jsx".to_string(),
5273                    "javascriptreact".to_string(),
5274                )]),
5275                name: None,
5276                only_features: None,
5277                except_features: None,
5278                root_markers: vec![
5279                    "tsconfig.json".to_string(),
5280                    "jsconfig.json".to_string(),
5281                    "package.json".to_string(),
5282                    ".git".to_string(),
5283                ],
5284            }]),
5285        );
5286        lsp.insert(
5287            "typescript".to_string(),
5288            LspLanguageConfig::Multi(vec![LspServerConfig {
5289                command: "typescript-language-server".to_string(),
5290                args: vec!["--stdio".to_string()],
5291                enabled: true,
5292                auto_start: false,
5293                process_limits: ProcessLimits::default(),
5294                initialization_options: None,
5295                env: Default::default(),
5296                language_id_overrides: HashMap::from([(
5297                    "tsx".to_string(),
5298                    "typescriptreact".to_string(),
5299                )]),
5300                name: None,
5301                only_features: None,
5302                except_features: None,
5303                root_markers: vec![
5304                    "tsconfig.json".to_string(),
5305                    "jsconfig.json".to_string(),
5306                    "package.json".to_string(),
5307                    ".git".to_string(),
5308                ],
5309            }]),
5310        );
5311
5312        // vscode-html-language-server (installed via npm install -g vscode-langservers-extracted)
5313        lsp.insert(
5314            "html".to_string(),
5315            LspLanguageConfig::Multi(vec![LspServerConfig {
5316                command: "vscode-html-language-server".to_string(),
5317                args: vec!["--stdio".to_string()],
5318                enabled: true,
5319                auto_start: false,
5320                process_limits: ProcessLimits::default(),
5321                initialization_options: None,
5322                env: Default::default(),
5323                language_id_overrides: Default::default(),
5324                name: None,
5325                only_features: None,
5326                except_features: None,
5327                root_markers: Default::default(),
5328            }]),
5329        );
5330
5331        // vscode-css-language-server (installed via npm install -g vscode-langservers-extracted)
5332        lsp.insert(
5333            "css".to_string(),
5334            LspLanguageConfig::Multi(vec![LspServerConfig {
5335                command: "vscode-css-language-server".to_string(),
5336                args: vec!["--stdio".to_string()],
5337                enabled: true,
5338                auto_start: false,
5339                process_limits: ProcessLimits::default(),
5340                initialization_options: None,
5341                env: Default::default(),
5342                language_id_overrides: Default::default(),
5343                name: None,
5344                only_features: None,
5345                except_features: None,
5346                root_markers: Default::default(),
5347            }]),
5348        );
5349
5350        // clangd (installed via package manager)
5351        lsp.insert(
5352            "c".to_string(),
5353            LspLanguageConfig::Multi(vec![LspServerConfig {
5354                command: "clangd".to_string(),
5355                args: vec![],
5356                enabled: true,
5357                auto_start: false,
5358                process_limits: ProcessLimits::default(),
5359                initialization_options: None,
5360                env: Default::default(),
5361                language_id_overrides: Default::default(),
5362                name: None,
5363                only_features: None,
5364                except_features: None,
5365                root_markers: vec![
5366                    "compile_commands.json".to_string(),
5367                    "CMakeLists.txt".to_string(),
5368                    "Makefile".to_string(),
5369                    ".git".to_string(),
5370                ],
5371            }]),
5372        );
5373        lsp.insert(
5374            "cpp".to_string(),
5375            LspLanguageConfig::Multi(vec![LspServerConfig {
5376                command: "clangd".to_string(),
5377                args: vec![],
5378                enabled: true,
5379                auto_start: false,
5380                process_limits: ProcessLimits::default(),
5381                initialization_options: None,
5382                env: Default::default(),
5383                language_id_overrides: Default::default(),
5384                name: None,
5385                only_features: None,
5386                except_features: None,
5387                root_markers: vec![
5388                    "compile_commands.json".to_string(),
5389                    "CMakeLists.txt".to_string(),
5390                    "Makefile".to_string(),
5391                    ".git".to_string(),
5392                ],
5393            }]),
5394        );
5395
5396        // gopls (installed via go install)
5397        lsp.insert(
5398            "go".to_string(),
5399            LspLanguageConfig::Multi(vec![LspServerConfig {
5400                command: "gopls".to_string(),
5401                args: vec![],
5402                enabled: true,
5403                auto_start: false,
5404                process_limits: ProcessLimits::default(),
5405                initialization_options: None,
5406                env: Default::default(),
5407                language_id_overrides: Default::default(),
5408                name: None,
5409                only_features: None,
5410                except_features: None,
5411                root_markers: vec![
5412                    "go.mod".to_string(),
5413                    "go.work".to_string(),
5414                    ".git".to_string(),
5415                ],
5416            }]),
5417        );
5418
5419        // vscode-json-language-server (installed via npm install -g vscode-langservers-extracted)
5420        lsp.insert(
5421            "json".to_string(),
5422            LspLanguageConfig::Multi(vec![LspServerConfig {
5423                command: "vscode-json-language-server".to_string(),
5424                args: vec!["--stdio".to_string()],
5425                enabled: true,
5426                auto_start: false,
5427                process_limits: ProcessLimits::default(),
5428                initialization_options: None,
5429                env: Default::default(),
5430                language_id_overrides: Default::default(),
5431                name: None,
5432                only_features: None,
5433                except_features: None,
5434                root_markers: Default::default(),
5435            }]),
5436        );
5437
5438        // Same server handles JSONC — the vscode-json-language-server accepts
5439        // the `jsonc` languageId and enables comment/trailing-comma tolerance
5440        // plus schema associations for well-known files.
5441        lsp.insert(
5442            "jsonc".to_string(),
5443            LspLanguageConfig::Multi(vec![LspServerConfig {
5444                command: "vscode-json-language-server".to_string(),
5445                args: vec!["--stdio".to_string()],
5446                enabled: true,
5447                auto_start: false,
5448                process_limits: ProcessLimits::default(),
5449                initialization_options: None,
5450                env: Default::default(),
5451                language_id_overrides: Default::default(),
5452                name: None,
5453                only_features: None,
5454                except_features: None,
5455                root_markers: Default::default(),
5456            }]),
5457        );
5458
5459        // csharp-language-server (installed via dotnet tool install -g csharp-ls)
5460        lsp.insert(
5461            "csharp".to_string(),
5462            LspLanguageConfig::Multi(vec![LspServerConfig {
5463                command: "csharp-ls".to_string(),
5464                args: vec![],
5465                enabled: true,
5466                auto_start: false,
5467                process_limits: ProcessLimits::default(),
5468                initialization_options: None,
5469                env: Default::default(),
5470                language_id_overrides: Default::default(),
5471                name: None,
5472                only_features: None,
5473                except_features: None,
5474                root_markers: vec![
5475                    "*.csproj".to_string(),
5476                    "*.sln".to_string(),
5477                    ".git".to_string(),
5478                ],
5479            }]),
5480        );
5481
5482        // ols - Odin Language Server (https://github.com/DanielGavin/ols)
5483        // Build from source: cd ols && ./build.sh (Linux/macOS) or ./build.bat (Windows)
5484        lsp.insert(
5485            "odin".to_string(),
5486            LspLanguageConfig::Multi(vec![LspServerConfig {
5487                command: "ols".to_string(),
5488                args: vec![],
5489                enabled: true,
5490                auto_start: false,
5491                process_limits: ProcessLimits::default(),
5492                initialization_options: None,
5493                env: Default::default(),
5494                language_id_overrides: Default::default(),
5495                name: None,
5496                only_features: None,
5497                except_features: None,
5498                root_markers: Default::default(),
5499            }]),
5500        );
5501
5502        // zls - Zig Language Server (https://github.com/zigtools/zls)
5503        // Install via package manager or download from releases
5504        lsp.insert(
5505            "zig".to_string(),
5506            LspLanguageConfig::Multi(vec![LspServerConfig {
5507                command: "zls".to_string(),
5508                args: vec![],
5509                enabled: true,
5510                auto_start: false,
5511                process_limits: ProcessLimits::default(),
5512                initialization_options: None,
5513                env: Default::default(),
5514                language_id_overrides: Default::default(),
5515                name: None,
5516                only_features: None,
5517                except_features: None,
5518                root_markers: Default::default(),
5519            }]),
5520        );
5521
5522        // jdtls - Eclipse JDT Language Server for Java
5523        // Install via package manager or download from Eclipse
5524        lsp.insert(
5525            "java".to_string(),
5526            LspLanguageConfig::Multi(vec![LspServerConfig {
5527                command: "jdtls".to_string(),
5528                args: vec![],
5529                enabled: true,
5530                auto_start: false,
5531                process_limits: ProcessLimits::default(),
5532                initialization_options: None,
5533                env: Default::default(),
5534                language_id_overrides: Default::default(),
5535                name: None,
5536                only_features: None,
5537                except_features: None,
5538                root_markers: vec![
5539                    "pom.xml".to_string(),
5540                    "build.gradle".to_string(),
5541                    "build.gradle.kts".to_string(),
5542                    ".git".to_string(),
5543                ],
5544            }]),
5545        );
5546
5547        // texlab - LaTeX Language Server (https://github.com/latex-lsp/texlab)
5548        // Install via cargo install texlab or package manager
5549        lsp.insert(
5550            "latex".to_string(),
5551            LspLanguageConfig::Multi(vec![LspServerConfig {
5552                command: "texlab".to_string(),
5553                args: vec![],
5554                enabled: true,
5555                auto_start: false,
5556                process_limits: ProcessLimits::default(),
5557                initialization_options: None,
5558                env: Default::default(),
5559                language_id_overrides: Default::default(),
5560                name: None,
5561                only_features: None,
5562                except_features: None,
5563                root_markers: Default::default(),
5564            }]),
5565        );
5566
5567        // marksman - Markdown Language Server (https://github.com/artempyanykh/marksman)
5568        // Install via package manager or download from releases
5569        lsp.insert(
5570            "markdown".to_string(),
5571            LspLanguageConfig::Multi(vec![LspServerConfig {
5572                command: "marksman".to_string(),
5573                args: vec!["server".to_string()],
5574                enabled: true,
5575                auto_start: false,
5576                process_limits: ProcessLimits::default(),
5577                initialization_options: None,
5578                env: Default::default(),
5579                language_id_overrides: Default::default(),
5580                name: None,
5581                only_features: None,
5582                except_features: None,
5583                root_markers: Default::default(),
5584            }]),
5585        );
5586
5587        // templ - Templ Language Server (https://templ.guide)
5588        // Install via go install github.com/a-h/templ/cmd/templ@latest
5589        lsp.insert(
5590            "templ".to_string(),
5591            LspLanguageConfig::Multi(vec![LspServerConfig {
5592                command: "templ".to_string(),
5593                args: vec!["lsp".to_string()],
5594                enabled: true,
5595                auto_start: false,
5596                process_limits: ProcessLimits::default(),
5597                initialization_options: None,
5598                env: Default::default(),
5599                language_id_overrides: Default::default(),
5600                name: None,
5601                only_features: None,
5602                except_features: None,
5603                root_markers: Default::default(),
5604            }]),
5605        );
5606
5607        // tinymist - Typst Language Server (https://github.com/Myriad-Dreamin/tinymist)
5608        // Install via cargo install tinymist or download from releases
5609        lsp.insert(
5610            "typst".to_string(),
5611            LspLanguageConfig::Multi(vec![LspServerConfig {
5612                command: "tinymist".to_string(),
5613                args: vec![],
5614                enabled: true,
5615                auto_start: false,
5616                process_limits: ProcessLimits::default(),
5617                initialization_options: None,
5618                env: Default::default(),
5619                language_id_overrides: Default::default(),
5620                name: None,
5621                only_features: None,
5622                except_features: None,
5623                root_markers: Default::default(),
5624            }]),
5625        );
5626
5627        // bash-language-server (installed via npm install -g bash-language-server)
5628        lsp.insert(
5629            "bash".to_string(),
5630            LspLanguageConfig::Multi(vec![LspServerConfig {
5631                command: "bash-language-server".to_string(),
5632                args: vec!["start".to_string()],
5633                enabled: true,
5634                auto_start: false,
5635                process_limits: ProcessLimits::default(),
5636                initialization_options: None,
5637                env: Default::default(),
5638                language_id_overrides: Default::default(),
5639                name: None,
5640                only_features: None,
5641                except_features: None,
5642                root_markers: Default::default(),
5643            }]),
5644        );
5645
5646        // lua-language-server (https://github.com/LuaLS/lua-language-server)
5647        // Install via package manager or download from releases
5648        lsp.insert(
5649            "lua".to_string(),
5650            LspLanguageConfig::Multi(vec![LspServerConfig {
5651                command: "lua-language-server".to_string(),
5652                args: vec![],
5653                enabled: true,
5654                auto_start: false,
5655                process_limits: ProcessLimits::default(),
5656                initialization_options: None,
5657                env: Default::default(),
5658                language_id_overrides: Default::default(),
5659                name: None,
5660                only_features: None,
5661                except_features: None,
5662                root_markers: vec![
5663                    ".luarc.json".to_string(),
5664                    ".luarc.jsonc".to_string(),
5665                    ".luacheckrc".to_string(),
5666                    ".stylua.toml".to_string(),
5667                    ".git".to_string(),
5668                ],
5669            }]),
5670        );
5671
5672        // solargraph - Ruby Language Server (installed via gem install solargraph)
5673        lsp.insert(
5674            "ruby".to_string(),
5675            LspLanguageConfig::Multi(vec![LspServerConfig {
5676                command: "solargraph".to_string(),
5677                args: vec!["stdio".to_string()],
5678                enabled: true,
5679                auto_start: false,
5680                process_limits: ProcessLimits::default(),
5681                initialization_options: None,
5682                env: Default::default(),
5683                language_id_overrides: Default::default(),
5684                name: None,
5685                only_features: None,
5686                except_features: None,
5687                root_markers: vec![
5688                    "Gemfile".to_string(),
5689                    ".ruby-version".to_string(),
5690                    ".git".to_string(),
5691                ],
5692            }]),
5693        );
5694
5695        // phpactor - PHP Language Server (https://phpactor.readthedocs.io)
5696        // Install via composer global require phpactor/phpactor
5697        lsp.insert(
5698            "php".to_string(),
5699            LspLanguageConfig::Multi(vec![LspServerConfig {
5700                command: "phpactor".to_string(),
5701                args: vec!["language-server".to_string()],
5702                enabled: true,
5703                auto_start: false,
5704                process_limits: ProcessLimits::default(),
5705                initialization_options: None,
5706                env: Default::default(),
5707                language_id_overrides: Default::default(),
5708                name: None,
5709                only_features: None,
5710                except_features: None,
5711                root_markers: vec!["composer.json".to_string(), ".git".to_string()],
5712            }]),
5713        );
5714
5715        // yaml-language-server (installed via npm install -g yaml-language-server)
5716        lsp.insert(
5717            "yaml".to_string(),
5718            LspLanguageConfig::Multi(vec![LspServerConfig {
5719                command: "yaml-language-server".to_string(),
5720                args: vec!["--stdio".to_string()],
5721                enabled: true,
5722                auto_start: false,
5723                process_limits: ProcessLimits::default(),
5724                initialization_options: None,
5725                env: Default::default(),
5726                language_id_overrides: Default::default(),
5727                name: None,
5728                only_features: None,
5729                except_features: None,
5730                root_markers: Default::default(),
5731            }]),
5732        );
5733
5734        // taplo - TOML Language Server (https://taplo.tamasfe.dev)
5735        // Install via cargo install taplo-cli or npm install -g @taplo/cli
5736        lsp.insert(
5737            "toml".to_string(),
5738            LspLanguageConfig::Multi(vec![LspServerConfig {
5739                command: "taplo".to_string(),
5740                args: vec!["lsp".to_string(), "stdio".to_string()],
5741                enabled: true,
5742                auto_start: false,
5743                process_limits: ProcessLimits::default(),
5744                initialization_options: None,
5745                env: Default::default(),
5746                language_id_overrides: Default::default(),
5747                name: None,
5748                only_features: None,
5749                except_features: None,
5750                root_markers: Default::default(),
5751            }]),
5752        );
5753
5754        // dart - Dart Language Server (#1252)
5755        // Included with the Dart SDK
5756        lsp.insert(
5757            "dart".to_string(),
5758            LspLanguageConfig::Multi(vec![LspServerConfig {
5759                command: "dart".to_string(),
5760                args: vec!["language-server".to_string(), "--protocol=lsp".to_string()],
5761                enabled: true,
5762                auto_start: false,
5763                process_limits: ProcessLimits::default(),
5764                initialization_options: None,
5765                env: Default::default(),
5766                language_id_overrides: Default::default(),
5767                name: None,
5768                only_features: None,
5769                except_features: None,
5770                root_markers: vec!["pubspec.yaml".to_string(), ".git".to_string()],
5771            }]),
5772        );
5773
5774        // nu - Nushell Language Server (#1031)
5775        // Built into the Nushell binary
5776        lsp.insert(
5777            "nushell".to_string(),
5778            LspLanguageConfig::Multi(vec![LspServerConfig {
5779                command: "nu".to_string(),
5780                args: vec!["--lsp".to_string()],
5781                enabled: true,
5782                auto_start: false,
5783                process_limits: ProcessLimits::default(),
5784                initialization_options: None,
5785                env: Default::default(),
5786                language_id_overrides: Default::default(),
5787                name: None,
5788                only_features: None,
5789                except_features: None,
5790                root_markers: Default::default(),
5791            }]),
5792        );
5793
5794        // solc - Solidity Language Server (#857)
5795        // Install via npm install -g @nomicfoundation/solidity-language-server
5796        lsp.insert(
5797            "solidity".to_string(),
5798            LspLanguageConfig::Multi(vec![LspServerConfig {
5799                command: "nomicfoundation-solidity-language-server".to_string(),
5800                args: vec!["--stdio".to_string()],
5801                enabled: true,
5802                auto_start: false,
5803                process_limits: ProcessLimits::default(),
5804                initialization_options: None,
5805                env: Default::default(),
5806                language_id_overrides: Default::default(),
5807                name: None,
5808                only_features: None,
5809                except_features: None,
5810                root_markers: Default::default(),
5811            }]),
5812        );
5813
5814        // --- DevOps / infrastructure LSP servers ---
5815
5816        // terraform-ls - Terraform Language Server (https://github.com/hashicorp/terraform-ls)
5817        // Install via package manager or download from releases
5818        lsp.insert(
5819            "terraform".to_string(),
5820            LspLanguageConfig::Multi(vec![LspServerConfig {
5821                command: "terraform-ls".to_string(),
5822                args: vec!["serve".to_string()],
5823                enabled: true,
5824                auto_start: false,
5825                process_limits: ProcessLimits::default(),
5826                initialization_options: None,
5827                env: Default::default(),
5828                language_id_overrides: Default::default(),
5829                name: None,
5830                only_features: None,
5831                except_features: None,
5832                root_markers: vec![
5833                    "*.tf".to_string(),
5834                    ".terraform".to_string(),
5835                    ".git".to_string(),
5836                ],
5837            }]),
5838        );
5839
5840        // cmake-language-server (https://github.com/regen100/cmake-language-server)
5841        // Install via pip: pip install cmake-language-server
5842        lsp.insert(
5843            "cmake".to_string(),
5844            LspLanguageConfig::Multi(vec![LspServerConfig {
5845                command: "cmake-language-server".to_string(),
5846                args: vec![],
5847                enabled: true,
5848                auto_start: false,
5849                process_limits: ProcessLimits::default(),
5850                initialization_options: None,
5851                env: Default::default(),
5852                language_id_overrides: Default::default(),
5853                name: None,
5854                only_features: None,
5855                except_features: None,
5856                root_markers: vec!["CMakeLists.txt".to_string(), ".git".to_string()],
5857            }]),
5858        );
5859
5860        // buf - Protobuf Language Server (https://buf.build)
5861        // Install via package manager or curl
5862        lsp.insert(
5863            "protobuf".to_string(),
5864            LspLanguageConfig::Multi(vec![LspServerConfig {
5865                command: "buf".to_string(),
5866                args: vec!["beta".to_string(), "lsp".to_string()],
5867                enabled: true,
5868                auto_start: false,
5869                process_limits: ProcessLimits::default(),
5870                initialization_options: None,
5871                env: Default::default(),
5872                language_id_overrides: Default::default(),
5873                name: None,
5874                only_features: None,
5875                except_features: None,
5876                root_markers: Default::default(),
5877            }]),
5878        );
5879
5880        // graphql-lsp (https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-cli)
5881        // Install via npm: npm install -g graphql-language-service-cli
5882        lsp.insert(
5883            "graphql".to_string(),
5884            LspLanguageConfig::Multi(vec![LspServerConfig {
5885                command: "graphql-lsp".to_string(),
5886                args: vec!["server".to_string(), "-m".to_string(), "stream".to_string()],
5887                enabled: true,
5888                auto_start: false,
5889                process_limits: ProcessLimits::default(),
5890                initialization_options: None,
5891                env: Default::default(),
5892                language_id_overrides: Default::default(),
5893                name: None,
5894                only_features: None,
5895                except_features: None,
5896                root_markers: Default::default(),
5897            }]),
5898        );
5899
5900        // sqls - SQL Language Server (https://github.com/sqls-server/sqls)
5901        // Install via go: go install github.com/sqls-server/sqls@latest
5902        lsp.insert(
5903            "sql".to_string(),
5904            LspLanguageConfig::Multi(vec![LspServerConfig {
5905                command: "sqls".to_string(),
5906                args: vec![],
5907                enabled: true,
5908                auto_start: false,
5909                process_limits: ProcessLimits::default(),
5910                initialization_options: None,
5911                env: Default::default(),
5912                language_id_overrides: Default::default(),
5913                name: None,
5914                only_features: None,
5915                except_features: None,
5916                root_markers: Default::default(),
5917            }]),
5918        );
5919
5920        // --- Web framework LSP servers ---
5921
5922        // vue-language-server (installed via npm install -g @vue/language-server)
5923        lsp.insert(
5924            "vue".to_string(),
5925            LspLanguageConfig::Multi(vec![LspServerConfig {
5926                command: "vue-language-server".to_string(),
5927                args: vec!["--stdio".to_string()],
5928                enabled: true,
5929                auto_start: false,
5930                process_limits: ProcessLimits::default(),
5931                initialization_options: None,
5932                env: Default::default(),
5933                language_id_overrides: Default::default(),
5934                name: None,
5935                only_features: None,
5936                except_features: None,
5937                root_markers: Default::default(),
5938            }]),
5939        );
5940
5941        // svelte-language-server (installed via npm install -g svelte-language-server)
5942        lsp.insert(
5943            "svelte".to_string(),
5944            LspLanguageConfig::Multi(vec![LspServerConfig {
5945                command: "svelteserver".to_string(),
5946                args: vec!["--stdio".to_string()],
5947                enabled: true,
5948                auto_start: false,
5949                process_limits: ProcessLimits::default(),
5950                initialization_options: None,
5951                env: Default::default(),
5952                language_id_overrides: Default::default(),
5953                name: None,
5954                only_features: None,
5955                except_features: None,
5956                root_markers: Default::default(),
5957            }]),
5958        );
5959
5960        // astro-ls - Astro Language Server (installed via npm install -g @astrojs/language-server)
5961        lsp.insert(
5962            "astro".to_string(),
5963            LspLanguageConfig::Multi(vec![LspServerConfig {
5964                command: "astro-ls".to_string(),
5965                args: vec!["--stdio".to_string()],
5966                enabled: true,
5967                auto_start: false,
5968                process_limits: ProcessLimits::default(),
5969                initialization_options: None,
5970                env: Default::default(),
5971                language_id_overrides: Default::default(),
5972                name: None,
5973                only_features: None,
5974                except_features: None,
5975                root_markers: Default::default(),
5976            }]),
5977        );
5978
5979        // tailwindcss-language-server (installed via npm install -g @tailwindcss/language-server)
5980        lsp.insert(
5981            "tailwindcss".to_string(),
5982            LspLanguageConfig::Multi(vec![LspServerConfig {
5983                command: "tailwindcss-language-server".to_string(),
5984                args: vec!["--stdio".to_string()],
5985                enabled: true,
5986                auto_start: false,
5987                process_limits: ProcessLimits::default(),
5988                initialization_options: None,
5989                env: Default::default(),
5990                language_id_overrides: Default::default(),
5991                name: None,
5992                only_features: None,
5993                except_features: None,
5994                root_markers: Default::default(),
5995            }]),
5996        );
5997
5998        // --- Programming language LSP servers ---
5999
6000        // nil - Nix Language Server (https://github.com/oxalica/nil)
6001        // Install via nix profile install github:oxalica/nil
6002        lsp.insert(
6003            "nix".to_string(),
6004            LspLanguageConfig::Multi(vec![LspServerConfig {
6005                command: "nil".to_string(),
6006                args: vec![],
6007                enabled: true,
6008                auto_start: false,
6009                process_limits: ProcessLimits::default(),
6010                initialization_options: None,
6011                env: Default::default(),
6012                language_id_overrides: Default::default(),
6013                name: None,
6014                only_features: None,
6015                except_features: None,
6016                root_markers: Default::default(),
6017            }]),
6018        );
6019
6020        // kotlin-language-server (https://github.com/fwcd/kotlin-language-server)
6021        // Install via package manager or build from source
6022        lsp.insert(
6023            "kotlin".to_string(),
6024            LspLanguageConfig::Multi(vec![LspServerConfig {
6025                command: "kotlin-language-server".to_string(),
6026                args: vec![],
6027                enabled: true,
6028                auto_start: false,
6029                process_limits: ProcessLimits::default(),
6030                initialization_options: None,
6031                env: Default::default(),
6032                language_id_overrides: Default::default(),
6033                name: None,
6034                only_features: None,
6035                except_features: None,
6036                root_markers: Default::default(),
6037            }]),
6038        );
6039
6040        // sourcekit-lsp - Swift Language Server (included with Swift toolchain)
6041        lsp.insert(
6042            "swift".to_string(),
6043            LspLanguageConfig::Multi(vec![LspServerConfig {
6044                command: "sourcekit-lsp".to_string(),
6045                args: vec![],
6046                enabled: true,
6047                auto_start: false,
6048                process_limits: ProcessLimits::default(),
6049                initialization_options: None,
6050                env: Default::default(),
6051                language_id_overrides: Default::default(),
6052                name: None,
6053                only_features: None,
6054                except_features: None,
6055                root_markers: Default::default(),
6056            }]),
6057        );
6058
6059        // metals - Scala Language Server (https://scalameta.org/metals/)
6060        // Install via coursier: cs install metals
6061        lsp.insert(
6062            "scala".to_string(),
6063            LspLanguageConfig::Multi(vec![LspServerConfig {
6064                command: "metals".to_string(),
6065                args: vec![],
6066                enabled: true,
6067                auto_start: false,
6068                process_limits: ProcessLimits::default(),
6069                initialization_options: None,
6070                env: Default::default(),
6071                language_id_overrides: Default::default(),
6072                name: None,
6073                only_features: None,
6074                except_features: None,
6075                root_markers: Default::default(),
6076            }]),
6077        );
6078
6079        // elixir-ls - Elixir Language Server (https://github.com/elixir-lsp/elixir-ls)
6080        // Install via mix: mix escript.install hex elixir_ls
6081        lsp.insert(
6082            "elixir".to_string(),
6083            LspLanguageConfig::Multi(vec![LspServerConfig {
6084                command: "elixir-ls".to_string(),
6085                args: vec![],
6086                enabled: true,
6087                auto_start: false,
6088                process_limits: ProcessLimits::default(),
6089                initialization_options: None,
6090                env: Default::default(),
6091                language_id_overrides: Default::default(),
6092                name: None,
6093                only_features: None,
6094                except_features: None,
6095                root_markers: Default::default(),
6096            }]),
6097        );
6098
6099        // erlang_ls - Erlang Language Server (https://github.com/erlang-ls/erlang_ls)
6100        lsp.insert(
6101            "erlang".to_string(),
6102            LspLanguageConfig::Multi(vec![LspServerConfig {
6103                command: "erlang_ls".to_string(),
6104                args: vec![],
6105                enabled: true,
6106                auto_start: false,
6107                process_limits: ProcessLimits::default(),
6108                initialization_options: None,
6109                env: Default::default(),
6110                language_id_overrides: Default::default(),
6111                name: None,
6112                only_features: None,
6113                except_features: None,
6114                root_markers: Default::default(),
6115            }]),
6116        );
6117
6118        // haskell-language-server (https://github.com/haskell/haskell-language-server)
6119        // Install via ghcup: ghcup install hls
6120        lsp.insert(
6121            "haskell".to_string(),
6122            LspLanguageConfig::Multi(vec![LspServerConfig {
6123                command: "haskell-language-server-wrapper".to_string(),
6124                args: vec!["--lsp".to_string()],
6125                enabled: true,
6126                auto_start: false,
6127                process_limits: ProcessLimits::default(),
6128                initialization_options: None,
6129                env: Default::default(),
6130                language_id_overrides: Default::default(),
6131                name: None,
6132                only_features: None,
6133                except_features: None,
6134                root_markers: Default::default(),
6135            }]),
6136        );
6137
6138        // ocamllsp - OCaml Language Server (https://github.com/ocaml/ocaml-lsp)
6139        // Install via opam: opam install ocaml-lsp-server
6140        lsp.insert(
6141            "ocaml".to_string(),
6142            LspLanguageConfig::Multi(vec![LspServerConfig {
6143                command: "ocamllsp".to_string(),
6144                args: vec![],
6145                enabled: true,
6146                auto_start: false,
6147                process_limits: ProcessLimits::default(),
6148                initialization_options: None,
6149                env: Default::default(),
6150                language_id_overrides: Default::default(),
6151                name: None,
6152                only_features: None,
6153                except_features: None,
6154                root_markers: Default::default(),
6155            }]),
6156        );
6157
6158        // clojure-lsp (https://github.com/clojure-lsp/clojure-lsp)
6159        // Install via package manager or download from releases
6160        lsp.insert(
6161            "clojure".to_string(),
6162            LspLanguageConfig::Multi(vec![LspServerConfig {
6163                command: "clojure-lsp".to_string(),
6164                args: vec![],
6165                enabled: true,
6166                auto_start: false,
6167                process_limits: ProcessLimits::default(),
6168                initialization_options: None,
6169                env: Default::default(),
6170                language_id_overrides: Default::default(),
6171                name: None,
6172                only_features: None,
6173                except_features: None,
6174                root_markers: Default::default(),
6175            }]),
6176        );
6177
6178        // r-languageserver (https://github.com/REditorSupport/languageserver)
6179        // Install via R: install.packages("languageserver")
6180        lsp.insert(
6181            "r".to_string(),
6182            LspLanguageConfig::Multi(vec![LspServerConfig {
6183                command: "R".to_string(),
6184                args: vec![
6185                    "--vanilla".to_string(),
6186                    "-e".to_string(),
6187                    "languageserver::run()".to_string(),
6188                ],
6189                enabled: true,
6190                auto_start: false,
6191                process_limits: ProcessLimits::default(),
6192                initialization_options: None,
6193                env: Default::default(),
6194                language_id_overrides: Default::default(),
6195                name: None,
6196                only_features: None,
6197                except_features: None,
6198                root_markers: Default::default(),
6199            }]),
6200        );
6201
6202        // julia LanguageServer.jl (https://github.com/julia-vscode/LanguageServer.jl)
6203        // Install via Julia: using Pkg; Pkg.add("LanguageServer")
6204        lsp.insert(
6205            "julia".to_string(),
6206            LspLanguageConfig::Multi(vec![LspServerConfig {
6207                command: "julia".to_string(),
6208                args: vec![
6209                    "--startup-file=no".to_string(),
6210                    "--history-file=no".to_string(),
6211                    "-e".to_string(),
6212                    "using LanguageServer; runserver()".to_string(),
6213                ],
6214                enabled: true,
6215                auto_start: false,
6216                process_limits: ProcessLimits::default(),
6217                initialization_options: None,
6218                env: Default::default(),
6219                language_id_overrides: Default::default(),
6220                name: None,
6221                only_features: None,
6222                except_features: None,
6223                root_markers: Default::default(),
6224            }]),
6225        );
6226
6227        // PerlNavigator (https://github.com/bscan/PerlNavigator)
6228        // Install via npm: npm install -g perlnavigator-server
6229        lsp.insert(
6230            "perl".to_string(),
6231            LspLanguageConfig::Multi(vec![LspServerConfig {
6232                command: "perlnavigator".to_string(),
6233                args: vec!["--stdio".to_string()],
6234                enabled: true,
6235                auto_start: false,
6236                process_limits: ProcessLimits::default(),
6237                initialization_options: None,
6238                env: Default::default(),
6239                language_id_overrides: Default::default(),
6240                name: None,
6241                only_features: None,
6242                except_features: None,
6243                root_markers: Default::default(),
6244            }]),
6245        );
6246
6247        // nimlangserver - Nim Language Server (https://github.com/nim-lang/langserver)
6248        // Install via nimble: nimble install nimlangserver
6249        lsp.insert(
6250            "nim".to_string(),
6251            LspLanguageConfig::Multi(vec![LspServerConfig {
6252                command: "nimlangserver".to_string(),
6253                args: vec![],
6254                enabled: true,
6255                auto_start: false,
6256                process_limits: ProcessLimits::default(),
6257                initialization_options: None,
6258                env: Default::default(),
6259                language_id_overrides: Default::default(),
6260                name: None,
6261                only_features: None,
6262                except_features: None,
6263                root_markers: Default::default(),
6264            }]),
6265        );
6266
6267        // gleam lsp - Gleam Language Server (built into the gleam binary)
6268        lsp.insert(
6269            "gleam".to_string(),
6270            LspLanguageConfig::Multi(vec![LspServerConfig {
6271                command: "gleam".to_string(),
6272                args: vec!["lsp".to_string()],
6273                enabled: true,
6274                auto_start: false,
6275                process_limits: ProcessLimits::default(),
6276                initialization_options: None,
6277                env: Default::default(),
6278                language_id_overrides: Default::default(),
6279                name: None,
6280                only_features: None,
6281                except_features: None,
6282                root_markers: Default::default(),
6283            }]),
6284        );
6285
6286        // racket-langserver - Racket Language Server
6287        // Install via: raco pkg install racket-langserver
6288        lsp.insert(
6289            "racket".to_string(),
6290            LspLanguageConfig::Multi(vec![LspServerConfig {
6291                command: "racket-langserver".to_string(),
6292                args: vec![],
6293                enabled: true,
6294                auto_start: false,
6295                process_limits: ProcessLimits::default(),
6296                initialization_options: None,
6297                env: Default::default(),
6298                language_id_overrides: Default::default(),
6299                name: None,
6300                only_features: None,
6301                except_features: None,
6302                root_markers: vec!["info.rkt".to_string(), ".git".to_string()],
6303            }]),
6304        );
6305
6306        // fsharp - F# Language Server (https://github.com/fsharp/FsAutoComplete)
6307        // Install via dotnet: dotnet tool install -g fsautocomplete
6308        lsp.insert(
6309            "fsharp".to_string(),
6310            LspLanguageConfig::Multi(vec![LspServerConfig {
6311                command: "fsautocomplete".to_string(),
6312                args: vec!["--adaptive-lsp-server-enabled".to_string()],
6313                enabled: true,
6314                auto_start: false,
6315                process_limits: ProcessLimits::default(),
6316                initialization_options: None,
6317                env: Default::default(),
6318                language_id_overrides: Default::default(),
6319                name: None,
6320                only_features: None,
6321                except_features: None,
6322                root_markers: Default::default(),
6323            }]),
6324        );
6325    }
6326    pub fn validate(&self) -> Result<(), ConfigError> {
6327        // Validate tab size
6328        if self.editor.tab_size == 0 {
6329            return Err(ConfigError::ValidationError(
6330                "tab_size must be greater than 0".to_string(),
6331            ));
6332        }
6333
6334        // Validate scroll offset
6335        if self.editor.scroll_offset > 100 {
6336            return Err(ConfigError::ValidationError(
6337                "scroll_offset must be <= 100".to_string(),
6338            ));
6339        }
6340
6341        // Validate keybindings
6342        for binding in &self.keybindings {
6343            if binding.key.is_empty() {
6344                return Err(ConfigError::ValidationError(
6345                    "keybinding key cannot be empty".to_string(),
6346                ));
6347            }
6348            if binding.action.is_empty() {
6349                return Err(ConfigError::ValidationError(
6350                    "keybinding action cannot be empty".to_string(),
6351                ));
6352            }
6353        }
6354
6355        Ok(())
6356    }
6357}
6358
6359/// Configuration error types
6360#[derive(Debug)]
6361pub enum ConfigError {
6362    IoError(String),
6363    ParseError(String),
6364    SerializeError(String),
6365    ValidationError(String),
6366}
6367
6368impl std::fmt::Display for ConfigError {
6369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6370        match self {
6371            Self::IoError(msg) => write!(f, "IO error: {msg}"),
6372            Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
6373            Self::SerializeError(msg) => write!(f, "Serialize error: {msg}"),
6374            Self::ValidationError(msg) => write!(f, "Validation error: {msg}"),
6375        }
6376    }
6377}
6378
6379impl std::error::Error for ConfigError {}
6380
6381#[cfg(test)]
6382mod tests {
6383    use super::*;
6384
6385    #[test]
6386    fn test_file_explorer_width_default_is_percent_30() {
6387        let cfg = FileExplorerConfig::default();
6388        assert_eq!(cfg.width, ExplorerWidth::Percent(30));
6389    }
6390
6391    // --- Wire format acceptance ---------------------------------------
6392
6393    #[test]
6394    fn test_width_accepts_legacy_float_fraction() {
6395        let cfg: FileExplorerConfig = serde_json::from_str(r#"{"width": 0.3}"#).unwrap();
6396        assert_eq!(cfg.width, ExplorerWidth::Percent(30));
6397    }
6398
6399    #[test]
6400    fn test_width_accepts_bare_integer_as_percent() {
6401        // Historical format from the earlier integer-percent PR.
6402        let cfg: FileExplorerConfig = serde_json::from_str(r#"{"width": 42}"#).unwrap();
6403        assert_eq!(cfg.width, ExplorerWidth::Percent(42));
6404    }
6405
6406    #[test]
6407    fn test_width_accepts_percent_string() {
6408        let cfg: FileExplorerConfig = serde_json::from_str(r#"{"width": "75%"}"#).unwrap();
6409        assert_eq!(cfg.width, ExplorerWidth::Percent(75));
6410        // Tolerate whitespace around the percent.
6411        let cfg: FileExplorerConfig = serde_json::from_str(r#"{"width": "42 %"}"#).unwrap();
6412        assert_eq!(cfg.width, ExplorerWidth::Percent(42));
6413    }
6414
6415    #[test]
6416    fn test_width_accepts_columns_string() {
6417        let cfg: FileExplorerConfig = serde_json::from_str(r#"{"width": "24"}"#).unwrap();
6418        assert_eq!(cfg.width, ExplorerWidth::Columns(24));
6419    }
6420
6421    #[test]
6422    fn test_width_rejects_percent_over_100() {
6423        let err = serde_json::from_str::<FileExplorerConfig>(r#"{"width": "150%"}"#)
6424            .expect_err("percent > 100 should be rejected");
6425        assert!(err.to_string().contains("100"), "{err}");
6426    }
6427
6428    #[test]
6429    fn test_width_rejects_integer_over_100() {
6430        // Bare integer is interpreted as percent, so >100 is an error.
6431        // Users who want 150 columns should write "150".
6432        let err = serde_json::from_str::<FileExplorerConfig>(r#"{"width": 150}"#)
6433            .expect_err("bare integer > 100 should be rejected as percent");
6434        assert!(err.to_string().contains("percent") || err.to_string().contains("100"));
6435    }
6436
6437    #[test]
6438    fn test_width_rejects_garbage_string() {
6439        serde_json::from_str::<FileExplorerConfig>(r#"{"width": "big"}"#)
6440            .expect_err("non-numeric string should be rejected");
6441    }
6442
6443    // --- Write-side format --------------------------------------------
6444
6445    #[test]
6446    fn test_width_serializes_percent_as_string_with_suffix() {
6447        let cfg = FileExplorerConfig {
6448            width: ExplorerWidth::Percent(30),
6449            ..Default::default()
6450        };
6451        let json = serde_json::to_value(&cfg).unwrap();
6452        assert_eq!(json["width"], serde_json::json!("30%"));
6453    }
6454
6455    #[test]
6456    fn test_width_serializes_columns_as_string_without_suffix() {
6457        let cfg = FileExplorerConfig {
6458            width: ExplorerWidth::Columns(24),
6459            ..Default::default()
6460        };
6461        let json = serde_json::to_value(&cfg).unwrap();
6462        assert_eq!(json["width"], serde_json::json!("24"));
6463    }
6464
6465    #[test]
6466    fn test_width_round_trip_both_variants() {
6467        for value in [ExplorerWidth::Percent(17), ExplorerWidth::Columns(42)] {
6468            let cfg = FileExplorerConfig {
6469                width: value,
6470                ..Default::default()
6471            };
6472            let json = serde_json::to_string(&cfg).unwrap();
6473            let restored: FileExplorerConfig = serde_json::from_str(&json).unwrap();
6474            assert_eq!(restored.width, value, "round trip failed for {:?}", value);
6475        }
6476    }
6477
6478    // --- to_cols -------------------------------------------------------
6479
6480    #[test]
6481    fn test_to_cols_percent() {
6482        assert_eq!(ExplorerWidth::Percent(30).to_cols(100), 30);
6483        assert_eq!(ExplorerWidth::Percent(25).to_cols(120), 30);
6484        // Zero-width terminal is degenerate; min-clamp can't fit, cap wins.
6485        assert_eq!(ExplorerWidth::Percent(30).to_cols(0), 0);
6486        assert_eq!(ExplorerWidth::Percent(100).to_cols(200), 200);
6487    }
6488
6489    #[test]
6490    fn test_to_cols_columns_clamps_to_terminal() {
6491        assert_eq!(ExplorerWidth::Columns(24).to_cols(100), 24);
6492        assert_eq!(ExplorerWidth::Columns(999).to_cols(80), 80);
6493    }
6494
6495    /// `to_cols` enforces a floor of `MIN_COLS` so the panel is always
6496    /// renderable, regardless of what the config/workspace asked for.
6497    #[test]
6498    fn test_to_cols_enforces_min_width() {
6499        // Tiny or zero configured values get bumped up to MIN_COLS.
6500        assert_eq!(
6501            ExplorerWidth::Columns(0).to_cols(100),
6502            ExplorerWidth::MIN_COLS
6503        );
6504        assert_eq!(
6505            ExplorerWidth::Columns(1).to_cols(100),
6506            ExplorerWidth::MIN_COLS
6507        );
6508        assert_eq!(
6509            ExplorerWidth::Columns(4).to_cols(100),
6510            ExplorerWidth::MIN_COLS
6511        );
6512        assert_eq!(
6513            ExplorerWidth::Percent(0).to_cols(100),
6514            ExplorerWidth::MIN_COLS
6515        );
6516        // 3% of 100 = 3, bumped up to MIN_COLS.
6517        assert_eq!(
6518            ExplorerWidth::Percent(3).to_cols(100),
6519            ExplorerWidth::MIN_COLS
6520        );
6521        // Just above the floor: pass through.
6522        assert_eq!(
6523            ExplorerWidth::Columns(ExplorerWidth::MIN_COLS + 1).to_cols(100),
6524            ExplorerWidth::MIN_COLS + 1
6525        );
6526    }
6527
6528    /// On very narrow terminals the `MIN_COLS` floor can't fit; the
6529    /// terminal-width cap wins and we return whatever columns exist.
6530    #[test]
6531    fn test_to_cols_min_floor_yields_to_narrow_terminal() {
6532        assert_eq!(ExplorerWidth::Columns(10).to_cols(3), 3);
6533        assert_eq!(ExplorerWidth::Percent(100).to_cols(2), 2);
6534        assert_eq!(ExplorerWidth::Columns(1).to_cols(0), 0);
6535    }
6536
6537    // --- load_from_file regression ------------------------------------
6538
6539    #[test]
6540    fn test_load_from_file_accepts_legacy_float_fraction_width() {
6541        let dir = tempfile::tempdir().unwrap();
6542        let path = dir.path().join("config.json");
6543        std::fs::write(&path, r#"{"file_explorer":{"width":0.25}}"#).unwrap();
6544        let cfg = Config::load_from_file(&path).expect("legacy float fraction must still load");
6545        assert_eq!(cfg.file_explorer.width, ExplorerWidth::Percent(25));
6546    }
6547
6548    #[test]
6549    fn test_load_from_file_accepts_columns_string_width() {
6550        let dir = tempfile::tempdir().unwrap();
6551        let path = dir.path().join("config.json");
6552        std::fs::write(&path, r#"{"file_explorer":{"width":"42"}}"#).unwrap();
6553        let cfg = Config::load_from_file(&path).unwrap();
6554        assert_eq!(cfg.file_explorer.width, ExplorerWidth::Columns(42));
6555    }
6556
6557    #[test]
6558    fn test_load_from_file_accepts_percent_string_width() {
6559        let dir = tempfile::tempdir().unwrap();
6560        let path = dir.path().join("config.json");
6561        std::fs::write(&path, r#"{"file_explorer":{"width":"55%"}}"#).unwrap();
6562        let cfg = Config::load_from_file(&path).unwrap();
6563        assert_eq!(cfg.file_explorer.width, ExplorerWidth::Percent(55));
6564    }
6565
6566    #[test]
6567    fn test_default_config() {
6568        let config = Config::default();
6569        assert_eq!(config.editor.tab_size, 4);
6570        assert!(config.editor.line_numbers);
6571        assert!(config.editor.syntax_highlighting);
6572        // keybindings is empty by design - it's for user customizations only
6573        // The actual keybindings come from resolve_keymap(active_keybinding_map)
6574        assert!(config.keybindings.is_empty());
6575        // But the resolved keymap should have bindings
6576        let resolved = config.resolve_keymap(&config.active_keybinding_map);
6577        assert!(!resolved.is_empty());
6578    }
6579
6580    #[test]
6581    fn test_all_builtin_keymaps_loadable() {
6582        for name in KeybindingMapName::BUILTIN_OPTIONS {
6583            let keymap = Config::load_builtin_keymap(name);
6584            assert!(keymap.is_some(), "Failed to load builtin keymap '{}'", name);
6585        }
6586    }
6587
6588    #[test]
6589    fn test_config_validation() {
6590        let mut config = Config::default();
6591        assert!(config.validate().is_ok());
6592
6593        config.editor.tab_size = 0;
6594        assert!(config.validate().is_err());
6595    }
6596
6597    #[test]
6598    fn test_macos_keymap_inherits_enter_bindings() {
6599        let config = Config::default();
6600        let bindings = config.resolve_keymap("macos");
6601
6602        let enter_bindings: Vec<_> = bindings.iter().filter(|b| b.key == "Enter").collect();
6603        assert!(
6604            !enter_bindings.is_empty(),
6605            "macos keymap should inherit Enter bindings from default, got {} Enter bindings",
6606            enter_bindings.len()
6607        );
6608        // Should have at least insert_newline for normal mode
6609        let has_insert_newline = enter_bindings.iter().any(|b| b.action == "insert_newline");
6610        assert!(
6611            has_insert_newline,
6612            "macos keymap should have insert_newline action for Enter key"
6613        );
6614    }
6615
6616    #[test]
6617    fn test_config_serialize_deserialize() {
6618        // Test that Config can be serialized and deserialized correctly
6619        let config = Config::default();
6620
6621        // Serialize to JSON
6622        let json = serde_json::to_string_pretty(&config).unwrap();
6623
6624        // Deserialize back
6625        let loaded: Config = serde_json::from_str(&json).unwrap();
6626
6627        assert_eq!(config.editor.tab_size, loaded.editor.tab_size);
6628        assert_eq!(config.theme, loaded.theme);
6629    }
6630
6631    #[test]
6632    fn test_config_with_custom_keybinding() {
6633        let json = r#"{
6634            "editor": {
6635                "tab_size": 2
6636            },
6637            "keybindings": [
6638                {
6639                    "key": "x",
6640                    "modifiers": ["ctrl", "shift"],
6641                    "action": "custom_action",
6642                    "args": {},
6643                    "when": null
6644                }
6645            ]
6646        }"#;
6647
6648        let config: Config = serde_json::from_str(json).unwrap();
6649        assert_eq!(config.editor.tab_size, 2);
6650        assert_eq!(config.keybindings.len(), 1);
6651        assert_eq!(config.keybindings[0].key, "x");
6652        assert_eq!(config.keybindings[0].modifiers.len(), 2);
6653    }
6654
6655    #[test]
6656    fn test_sparse_config_merges_with_defaults() {
6657        // User config that only specifies one LSP server
6658        let temp_dir = tempfile::tempdir().unwrap();
6659        let config_path = temp_dir.path().join("config.json");
6660
6661        // Write a sparse config - only overriding rust LSP
6662        let sparse_config = r#"{
6663            "lsp": {
6664                "rust": {
6665                    "command": "custom-rust-analyzer",
6666                    "args": ["--custom-arg"]
6667                }
6668            }
6669        }"#;
6670        std::fs::write(&config_path, sparse_config).unwrap();
6671
6672        // Load the config - should merge with defaults
6673        let loaded = Config::load_from_file(&config_path).unwrap();
6674
6675        // User's rust override should be present
6676        assert!(loaded.lsp.contains_key("rust"));
6677        assert_eq!(
6678            loaded.lsp["rust"].as_slice()[0].command,
6679            "custom-rust-analyzer".to_string()
6680        );
6681
6682        // Default LSP servers should also be present (merged from defaults)
6683        assert!(
6684            loaded.lsp.contains_key("python"),
6685            "python LSP should be merged from defaults"
6686        );
6687        assert!(
6688            loaded.lsp.contains_key("typescript"),
6689            "typescript LSP should be merged from defaults"
6690        );
6691        assert!(
6692            loaded.lsp.contains_key("javascript"),
6693            "javascript LSP should be merged from defaults"
6694        );
6695
6696        // Default language configs should also be present
6697        assert!(loaded.languages.contains_key("rust"));
6698        assert!(loaded.languages.contains_key("python"));
6699        assert!(loaded.languages.contains_key("typescript"));
6700    }
6701
6702    #[test]
6703    fn test_empty_config_gets_all_defaults() {
6704        let temp_dir = tempfile::tempdir().unwrap();
6705        let config_path = temp_dir.path().join("config.json");
6706
6707        // Write an empty config
6708        std::fs::write(&config_path, "{}").unwrap();
6709
6710        let loaded = Config::load_from_file(&config_path).unwrap();
6711        let defaults = Config::default();
6712
6713        // Should have all default LSP servers
6714        assert_eq!(loaded.lsp.len(), defaults.lsp.len());
6715
6716        // Should have all default languages
6717        assert_eq!(loaded.languages.len(), defaults.languages.len());
6718    }
6719
6720    #[test]
6721    fn test_dynamic_submenu_expansion() {
6722        // Test that DynamicSubmenu expands to Submenu with generated items
6723        let temp_dir = tempfile::tempdir().unwrap();
6724        let themes_dir = temp_dir.path().to_path_buf();
6725
6726        let dynamic = MenuItem::DynamicSubmenu {
6727            label: "Test".to_string(),
6728            source: "copy_with_theme".to_string(),
6729        };
6730
6731        let expanded = dynamic.expand_dynamic(&themes_dir);
6732
6733        // Should expand to a Submenu
6734        match expanded {
6735            MenuItem::Submenu { label, items } => {
6736                assert_eq!(label, "Test");
6737                // Should have items for each available theme (embedded themes only, no user themes in temp dir)
6738                let loader = crate::view::theme::ThemeLoader::embedded_only();
6739                let registry = loader.load_all(&[]);
6740                assert_eq!(items.len(), registry.len());
6741
6742                // Each item should be an Action with copy_with_theme
6743                for (item, theme_info) in items.iter().zip(registry.list().iter()) {
6744                    match item {
6745                        MenuItem::Action {
6746                            label,
6747                            action,
6748                            args,
6749                            ..
6750                        } => {
6751                            assert_eq!(label, &theme_info.name);
6752                            assert_eq!(action, "copy_with_theme");
6753                            assert_eq!(
6754                                args.get("theme").and_then(|v| v.as_str()),
6755                                Some(theme_info.name.as_str())
6756                            );
6757                        }
6758                        _ => panic!("Expected Action item"),
6759                    }
6760                }
6761            }
6762            _ => panic!("Expected Submenu after expansion"),
6763        }
6764    }
6765
6766    #[test]
6767    fn test_non_dynamic_item_unchanged() {
6768        // Non-DynamicSubmenu items should be unchanged by expand_dynamic
6769        let temp_dir = tempfile::tempdir().unwrap();
6770        let themes_dir = temp_dir.path();
6771
6772        let action = MenuItem::Action {
6773            label: "Test".to_string(),
6774            action: "test".to_string(),
6775            args: HashMap::new(),
6776            when: None,
6777            checkbox: None,
6778        };
6779
6780        let expanded = action.expand_dynamic(themes_dir);
6781        match expanded {
6782            MenuItem::Action { label, action, .. } => {
6783                assert_eq!(label, "Test");
6784                assert_eq!(action, "test");
6785            }
6786            _ => panic!("Action should remain Action after expand_dynamic"),
6787        }
6788    }
6789
6790    #[test]
6791    fn test_buffer_config_uses_global_defaults() {
6792        let config = Config::default();
6793        let buffer_config = BufferConfig::resolve(&config, None);
6794
6795        assert_eq!(buffer_config.tab_size, config.editor.tab_size);
6796        assert_eq!(buffer_config.auto_indent, config.editor.auto_indent);
6797        assert!(!buffer_config.use_tabs); // Default is spaces
6798        assert!(buffer_config.whitespace.any_tabs()); // Tabs visible by default
6799        assert!(buffer_config.formatter.is_none());
6800        assert!(!buffer_config.format_on_save);
6801    }
6802
6803    #[test]
6804    fn test_buffer_config_applies_language_overrides() {
6805        let mut config = Config::default();
6806
6807        // Add a language config with custom settings
6808        config.languages.insert(
6809            "go".to_string(),
6810            LanguageConfig {
6811                extensions: vec!["go".to_string()],
6812                filenames: vec![],
6813                grammar: "go".to_string(),
6814                comment_prefix: Some("//".to_string()),
6815                auto_indent: true,
6816                auto_close: None,
6817                auto_surround: None,
6818                textmate_grammar: None,
6819                show_whitespace_tabs: false, // Go hides tab indicators
6820                line_wrap: None,
6821                wrap_column: None,
6822                page_view: None,
6823                page_width: None,
6824                use_tabs: Some(true), // Go uses tabs
6825                tab_size: Some(8),    // Go uses 8-space tabs
6826                formatter: Some(FormatterConfig {
6827                    command: "gofmt".to_string(),
6828                    args: vec![],
6829                    stdin: true,
6830                    timeout_ms: 10000,
6831                }),
6832                format_on_save: true,
6833                on_save: vec![],
6834                word_characters: None,
6835            },
6836        );
6837
6838        let buffer_config = BufferConfig::resolve(&config, Some("go"));
6839
6840        assert_eq!(buffer_config.tab_size, 8);
6841        assert!(buffer_config.use_tabs);
6842        assert!(!buffer_config.whitespace.any_tabs()); // Go disables tab indicators
6843        assert!(buffer_config.format_on_save);
6844        assert!(buffer_config.formatter.is_some());
6845        assert_eq!(buffer_config.formatter.as_ref().unwrap().command, "gofmt");
6846    }
6847
6848    #[test]
6849    fn test_buffer_config_unknown_language_uses_global() {
6850        let config = Config::default();
6851        let buffer_config = BufferConfig::resolve(&config, Some("unknown_lang"));
6852
6853        // Should fall back to global settings
6854        assert_eq!(buffer_config.tab_size, config.editor.tab_size);
6855        assert!(!buffer_config.use_tabs);
6856    }
6857
6858    #[test]
6859    fn test_buffer_config_per_language_line_wrap() {
6860        let mut config = Config::default();
6861        config.editor.line_wrap = false;
6862
6863        // Add markdown with line_wrap override
6864        config.languages.insert(
6865            "markdown".to_string(),
6866            LanguageConfig {
6867                extensions: vec!["md".to_string()],
6868                line_wrap: Some(true),
6869                ..Default::default()
6870            },
6871        );
6872
6873        // Markdown should override global line_wrap=false
6874        let md_config = BufferConfig::resolve(&config, Some("markdown"));
6875        assert!(md_config.line_wrap, "Markdown should have line_wrap=true");
6876
6877        // Other languages should use global default (false)
6878        let other_config = BufferConfig::resolve(&config, Some("rust"));
6879        assert!(
6880            !other_config.line_wrap,
6881            "Non-configured languages should use global line_wrap=false"
6882        );
6883
6884        // No language should use global default
6885        let no_lang_config = BufferConfig::resolve(&config, None);
6886        assert!(
6887            !no_lang_config.line_wrap,
6888            "No language should use global line_wrap=false"
6889        );
6890    }
6891
6892    #[test]
6893    fn test_buffer_config_per_language_wrap_column() {
6894        let mut config = Config::default();
6895        config.editor.wrap_column = Some(120);
6896
6897        // Add markdown with wrap_column override
6898        config.languages.insert(
6899            "markdown".to_string(),
6900            LanguageConfig {
6901                extensions: vec!["md".to_string()],
6902                wrap_column: Some(80),
6903                ..Default::default()
6904            },
6905        );
6906
6907        // Markdown should use its own wrap_column
6908        let md_config = BufferConfig::resolve(&config, Some("markdown"));
6909        assert_eq!(md_config.wrap_column, Some(80));
6910
6911        // Other languages should use global wrap_column
6912        let other_config = BufferConfig::resolve(&config, Some("rust"));
6913        assert_eq!(other_config.wrap_column, Some(120));
6914
6915        // No language should use global wrap_column
6916        let no_lang_config = BufferConfig::resolve(&config, None);
6917        assert_eq!(no_lang_config.wrap_column, Some(120));
6918    }
6919
6920    #[test]
6921    fn test_buffer_config_indent_string() {
6922        let config = Config::default();
6923
6924        // Spaces indent
6925        let spaces_config = BufferConfig::resolve(&config, None);
6926        assert_eq!(spaces_config.indent_string(), "    "); // 4 spaces
6927
6928        // Tabs indent - create a language that uses tabs
6929        let mut config_with_tabs = Config::default();
6930        config_with_tabs.languages.insert(
6931            "makefile".to_string(),
6932            LanguageConfig {
6933                use_tabs: Some(true),
6934                tab_size: Some(8),
6935                ..Default::default()
6936            },
6937        );
6938        let tabs_config = BufferConfig::resolve(&config_with_tabs, Some("makefile"));
6939        assert_eq!(tabs_config.indent_string(), "\t");
6940    }
6941
6942    #[test]
6943    fn test_buffer_config_global_use_tabs_inherited() {
6944        // When editor.use_tabs is true, buffers without a language-specific
6945        // override should inherit the global setting.
6946        let mut config = Config::default();
6947        config.editor.use_tabs = true;
6948
6949        // Unknown language inherits global
6950        let buffer_config = BufferConfig::resolve(&config, Some("unknown_lang"));
6951        assert!(buffer_config.use_tabs);
6952
6953        // No language inherits global
6954        let buffer_config = BufferConfig::resolve(&config, None);
6955        assert!(buffer_config.use_tabs);
6956
6957        // Language with explicit use_tabs: Some(false) overrides global
6958        config.languages.insert(
6959            "python".to_string(),
6960            LanguageConfig {
6961                use_tabs: Some(false),
6962                ..Default::default()
6963            },
6964        );
6965        let buffer_config = BufferConfig::resolve(&config, Some("python"));
6966        assert!(!buffer_config.use_tabs);
6967
6968        // Language with use_tabs: None inherits global true
6969        config.languages.insert(
6970            "rust".to_string(),
6971            LanguageConfig {
6972                use_tabs: None,
6973                ..Default::default()
6974            },
6975        );
6976        let buffer_config = BufferConfig::resolve(&config, Some("rust"));
6977        assert!(buffer_config.use_tabs);
6978    }
6979
6980    /// Verify that every LSP config key has a matching entry in default_languages().
6981    /// Without this, detect_language() won't map file extensions to the language name,
6982    /// causing "No LSP server configured for this file type" even though the LSP config
6983    /// exists. The only exception is "tailwindcss" which attaches to CSS/HTML/JS files
6984    /// rather than having its own file type.
6985    #[test]
6986    #[cfg(feature = "runtime")]
6987    fn test_lsp_languages_have_language_config() {
6988        let config = Config::default();
6989        let exceptions = ["tailwindcss"];
6990        for lsp_key in config.lsp.keys() {
6991            if exceptions.contains(&lsp_key.as_str()) {
6992                continue;
6993            }
6994            assert!(
6995                config.languages.contains_key(lsp_key),
6996                "LSP config key '{}' has no matching entry in default_languages(). \
6997                 Add a LanguageConfig with the correct file extensions so detect_language() \
6998                 can map files to this language.",
6999                lsp_key
7000            );
7001        }
7002    }
7003
7004    #[test]
7005    #[cfg(feature = "runtime")]
7006    fn test_default_config_has_quicklsp_in_universal_lsp() {
7007        let config = Config::default();
7008        assert!(
7009            config.universal_lsp.contains_key("quicklsp"),
7010            "Default config should contain quicklsp in universal_lsp"
7011        );
7012        let quicklsp = &config.universal_lsp["quicklsp"];
7013        let server = &quicklsp.as_slice()[0];
7014        assert_eq!(server.command, "quicklsp");
7015        assert!(!server.enabled, "quicklsp should be disabled by default");
7016        assert_eq!(server.name.as_deref(), Some("QuickLSP"));
7017        // only_features must stay unset so quicklsp can serve every capability
7018        // its server advertises — including go-to-definition. A restrictive
7019        // whitelist here silently breaks F12 for users on a vanilla install.
7020        assert!(
7021            server.only_features.is_none(),
7022            "quicklsp must not default to a feature whitelist"
7023        );
7024        assert!(server.except_features.is_none());
7025    }
7026
7027    #[test]
7028    fn test_empty_config_preserves_universal_lsp_defaults() {
7029        let temp_dir = tempfile::tempdir().unwrap();
7030        let config_path = temp_dir.path().join("config.json");
7031
7032        // Write an empty config
7033        std::fs::write(&config_path, "{}").unwrap();
7034
7035        let loaded = Config::load_from_file(&config_path).unwrap();
7036        let defaults = Config::default();
7037
7038        // Should have all default universal LSP servers
7039        assert_eq!(
7040            loaded.universal_lsp.len(),
7041            defaults.universal_lsp.len(),
7042            "Empty config should preserve all default universal_lsp entries"
7043        );
7044    }
7045
7046    #[test]
7047    fn test_universal_lsp_config_merges_with_defaults() {
7048        let temp_dir = tempfile::tempdir().unwrap();
7049        let config_path = temp_dir.path().join("config.json");
7050
7051        // Write a config that enables quicklsp
7052        let config_json = r#"{
7053            "universal_lsp": {
7054                "quicklsp": {
7055                    "enabled": true
7056                }
7057            }
7058        }"#;
7059        std::fs::write(&config_path, config_json).unwrap();
7060
7061        let loaded = Config::load_from_file(&config_path).unwrap();
7062
7063        // quicklsp should be enabled (user override)
7064        assert!(loaded.universal_lsp.contains_key("quicklsp"));
7065        let server = &loaded.universal_lsp["quicklsp"].as_slice()[0];
7066        assert!(server.enabled, "User override should enable quicklsp");
7067        // Command should be merged from defaults
7068        assert_eq!(
7069            server.command, "quicklsp",
7070            "Default command should be merged when not specified by user"
7071        );
7072    }
7073
7074    #[test]
7075    fn test_universal_lsp_custom_server_added() {
7076        let temp_dir = tempfile::tempdir().unwrap();
7077        let config_path = temp_dir.path().join("config.json");
7078
7079        // Write a config that adds a custom universal server
7080        let config_json = r#"{
7081            "universal_lsp": {
7082                "my-custom-server": {
7083                    "command": "my-server",
7084                    "enabled": true,
7085                    "auto_start": true
7086                }
7087            }
7088        }"#;
7089        std::fs::write(&config_path, config_json).unwrap();
7090
7091        let loaded = Config::load_from_file(&config_path).unwrap();
7092
7093        // Custom server should be present
7094        assert!(
7095            loaded.universal_lsp.contains_key("my-custom-server"),
7096            "Custom universal server should be loaded"
7097        );
7098        let server = &loaded.universal_lsp["my-custom-server"].as_slice()[0];
7099        assert_eq!(server.command, "my-server");
7100        assert!(server.enabled);
7101        assert!(server.auto_start);
7102
7103        // Default quicklsp should also still be present
7104        assert!(
7105            loaded.universal_lsp.contains_key("quicklsp"),
7106            "Default quicklsp should be merged from defaults"
7107        );
7108    }
7109}