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