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