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