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