Skip to main content

fresh/
config.rs

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