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