Skip to main content

mq_edit/
config.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use markdown_lsp;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// LSP configuration for language servers
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct LspConfig {
10    /// Language server configurations by language ID
11    #[serde(default)]
12    pub servers: HashMap<String, LspServerConfig>,
13}
14
15impl Default for LspConfig {
16    fn default() -> Self {
17        let mut servers = HashMap::new();
18
19        // Add default Rust analyzer configuration
20        servers.insert(
21            "rust".to_string(),
22            LspServerConfig {
23                command: "rust-analyzer".to_string(),
24                args: vec![],
25                embedded: false,
26                enable_completion: true,
27                enable_diagnostics: true,
28                enable_goto_definition: true,
29            },
30        );
31
32        // Add default Python language server configuration
33        servers.insert(
34            "python".to_string(),
35            LspServerConfig {
36                command: "pyright-langserver".to_string(),
37                args: vec!["--stdio".to_string()],
38                embedded: false,
39                enable_completion: true,
40                enable_diagnostics: true,
41                enable_goto_definition: true,
42            },
43        );
44
45        // Add default MQ language server configuration
46        servers.insert(
47            "mq".to_string(),
48            LspServerConfig {
49                command: "mq-lsp".to_string(),
50                args: vec![],
51                embedded: false,
52                enable_completion: true,
53                enable_diagnostics: true,
54                enable_goto_definition: true,
55            },
56        );
57
58        // Add default Markdown embedded LSP configuration
59        servers.insert(
60            "markdown".to_string(),
61            LspServerConfig {
62                command: String::new(),
63                args: vec![],
64                embedded: true,
65                enable_completion: true,
66                enable_diagnostics: true,
67                enable_goto_definition: true,
68            },
69        );
70
71        Self { servers }
72    }
73}
74
75/// Configuration for a specific LSP server
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LspServerConfig {
78    /// Command to start the LSP server (e.g., "rust-analyzer")
79    /// Not required for embedded LSP servers
80    #[serde(default)]
81    pub command: String,
82
83    /// Command-line arguments for the server
84    #[serde(default)]
85    pub args: Vec<String>,
86
87    /// Use embedded LSP implementation instead of external process
88    /// When true, the command field is ignored
89    #[serde(default)]
90    pub embedded: bool,
91
92    /// Enable code completion
93    #[serde(default = "default_true")]
94    pub enable_completion: bool,
95
96    /// Enable diagnostics (errors, warnings)
97    #[serde(default = "default_true")]
98    pub enable_diagnostics: bool,
99
100    /// Enable go-to-definition
101    #[serde(default = "default_true")]
102    pub enable_goto_definition: bool,
103}
104
105fn default_true() -> bool {
106    true
107}
108
109fn default_false() -> bool {
110    false
111}
112
113/// Editor display configuration
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct EditorConfig {
116    /// Show line numbers in the editor
117    #[serde(default = "default_true")]
118    pub show_line_numbers: bool,
119
120    /// Highlight the current line
121    #[serde(default = "default_true")]
122    pub show_current_line_highlight: bool,
123
124    /// Syntax highlighting theme
125    #[serde(default = "default_theme")]
126    pub theme: String,
127
128    /// Use semantic tokens from LSP for syntax highlighting
129    /// When false, falls back to syntect (default: false)
130    #[serde(default = "default_false")]
131    pub use_semantic_tokens: bool,
132}
133
134impl Default for EditorConfig {
135    fn default() -> Self {
136        Self {
137            show_line_numbers: true,
138            show_current_line_highlight: true,
139            theme: default_theme(),
140            use_semantic_tokens: false,
141        }
142    }
143}
144
145fn default_theme() -> String {
146    "base16-ocean.dark".to_string()
147}
148
149/// Application configuration
150#[derive(Debug, Clone, Serialize, Deserialize, Default)]
151pub struct Config {
152    #[serde(default)]
153    pub editor: EditorConfig,
154    pub keybindings: Keybindings,
155    #[serde(default)]
156    pub lsp: LspConfig,
157}
158
159impl Config {
160    /// Load configuration from file
161    pub fn load_from_file(path: impl AsRef<Path>) -> miette::Result<Self> {
162        let content = std::fs::read_to_string(path.as_ref())
163            .map_err(|e| miette::miette!("Failed to read config file: {}", e))?;
164
165        toml::from_str(&content).map_err(|e| miette::miette!("Failed to parse config file: {}", e))
166    }
167
168    /// Save configuration to file
169    pub fn save_to_file(&self, path: impl AsRef<Path>) -> miette::Result<()> {
170        let content = toml::to_string_pretty(self)
171            .map_err(|e| miette::miette!("Failed to serialize config: {}", e))?;
172
173        std::fs::write(path.as_ref(), content)
174            .map_err(|e| miette::miette!("Failed to write config file: {}", e))?;
175
176        Ok(())
177    }
178
179    /// Load configuration from default location or use defaults
180    pub fn load_or_default() -> Self {
181        let config_path = Self::default_config_path();
182
183        if config_path.exists() {
184            Self::load_from_file(&config_path).unwrap_or_default()
185        } else {
186            Self::default()
187        }
188    }
189
190    /// Get default config file path
191    pub fn default_config_path() -> std::path::PathBuf {
192        if let Some(config_dir) = dirs::config_dir() {
193            config_dir.join("mq").join("edit").join("config.toml")
194        } else {
195            std::path::PathBuf::from(".mq-edit.toml")
196        }
197    }
198
199    /// Convert LSP server configs to markdown_lsp format
200    pub fn lsp_server_configs(&self) -> HashMap<String, markdown_lsp::LspServerConfig> {
201        self.lsp
202            .servers
203            .iter()
204            .map(|(key, config)| {
205                (
206                    key.clone(),
207                    markdown_lsp::LspServerConfig {
208                        command: config.command.clone(),
209                        args: config.args.clone(),
210                        embedded: config.embedded,
211                        enable_completion: config.enable_completion,
212                        enable_diagnostics: config.enable_diagnostics,
213                        enable_goto_definition: config.enable_goto_definition,
214                    },
215                )
216            })
217            .collect()
218    }
219}
220
221/// Keybindings configuration
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Keybindings {
224    /// Quit application (default: Ctrl+Q)
225    pub quit: KeyBinding,
226
227    /// Alternative quit binding (default: Esc)
228    pub quit_alt: KeyBinding,
229
230    /// Save file (default: Ctrl+S)
231    pub save: KeyBinding,
232
233    /// Toggle file browser (default: Alt+B to avoid VSCode/Zellij conflicts)
234    pub toggle_file_browser: KeyBinding,
235
236    /// Alternative toggle file browser (default: F2)
237    pub toggle_file_browser_alt: KeyBinding,
238
239    /// Go to definition (default: Ctrl+G)
240    pub goto_definition: KeyBinding,
241
242    /// Navigate back in history (default: Ctrl+B)
243    pub navigate_back: KeyBinding,
244
245    /// Navigate forward in history (default: Ctrl+Shift+B)
246    pub navigate_forward: KeyBinding,
247
248    /// Search (planned for Phase 4)
249    pub search: KeyBinding,
250
251    /// Replace (planned for Phase 4)
252    pub replace: KeyBinding,
253
254    /// Undo (planned for Phase 4)
255    pub undo: KeyBinding,
256
257    /// Redo (planned for Phase 4)
258    pub redo: KeyBinding,
259
260    /// Close file browser
261    pub close_browser: KeyBinding,
262
263    /// Toggle line numbers display
264    pub toggle_line_numbers: KeyBinding,
265
266    /// Toggle current line highlight
267    pub toggle_current_line_highlight: KeyBinding,
268
269    /// Go to line (default: Ctrl+G)
270    pub goto_line: KeyBinding,
271
272    /// Execute mq query (default: Ctrl+E)
273    pub execute_mq_query: KeyBinding,
274}
275
276impl Default for Keybindings {
277    fn default() -> Self {
278        Self {
279            // Use Ctrl+Q for quit
280            quit: KeyBinding {
281                code: "q".to_string(),
282                modifiers: vec!["ctrl".to_string()],
283            },
284            // Alternative quit with Esc
285            quit_alt: KeyBinding {
286                code: "esc".to_string(),
287                modifiers: vec![],
288            },
289            // Ctrl+S is pretty universal for save
290            save: KeyBinding {
291                code: "s".to_string(),
292                modifiers: vec!["ctrl".to_string()],
293            },
294            // Use Alt+B instead of Ctrl+B to avoid navigation conflicts
295            toggle_file_browser: KeyBinding {
296                code: "b".to_string(),
297                modifiers: vec!["alt".to_string()],
298            },
299            toggle_file_browser_alt: KeyBinding {
300                code: "f2".to_string(),
301                modifiers: vec![],
302            },
303            // Ctrl+D for go to definition
304            goto_definition: KeyBinding {
305                code: "d".to_string(),
306                modifiers: vec!["ctrl".to_string()],
307            },
308            // Ctrl+B for navigate back (like browser back button)
309            navigate_back: KeyBinding {
310                code: "b".to_string(),
311                modifiers: vec!["ctrl".to_string()],
312            },
313            // Ctrl+F for navigate forward (like browser forward button)
314            navigate_forward: KeyBinding {
315                code: "f".to_string(),
316                modifiers: vec!["ctrl".to_string()],
317            },
318            // F3 for search (avoids vim conflicts with Ctrl+F, Ctrl+/, etc.)
319            search: KeyBinding {
320                code: "f3".to_string(),
321                modifiers: vec![],
322            },
323            // F4 for replace (avoids vim conflicts)
324            replace: KeyBinding {
325                code: "f4".to_string(),
326                modifiers: vec![],
327            },
328            // Ctrl+Z for undo (standard)
329            undo: KeyBinding {
330                code: "z".to_string(),
331                modifiers: vec!["ctrl".to_string()],
332            },
333            // Ctrl+Y for redo (standard on Windows/Linux)
334            redo: KeyBinding {
335                code: "y".to_string(),
336                modifiers: vec!["ctrl".to_string()],
337            },
338            // Esc to close browser (standard)
339            close_browser: KeyBinding {
340                code: "esc".to_string(),
341                modifiers: vec![],
342            },
343            // Ctrl+L for toggle line numbers
344            toggle_line_numbers: KeyBinding {
345                code: "l".to_string(),
346                modifiers: vec!["ctrl".to_string()],
347            },
348            // Ctrl+Shift+L for toggle current line highlight
349            toggle_current_line_highlight: KeyBinding {
350                code: "l".to_string(),
351                modifiers: vec!["ctrl".to_string(), "shift".to_string()],
352            },
353            // Ctrl+G for go to line (like vim)
354            goto_line: KeyBinding {
355                code: "g".to_string(),
356                modifiers: vec!["ctrl".to_string()],
357            },
358            // Ctrl+E for execute mq query
359            execute_mq_query: KeyBinding {
360                code: "e".to_string(),
361                modifiers: vec!["ctrl".to_string()],
362            },
363        }
364    }
365}
366
367/// Represents a key binding with modifiers
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct KeyBinding {
370    pub code: String,
371    pub modifiers: Vec<String>,
372}
373
374impl KeyBinding {
375    /// Check if this keybinding matches a KeyEvent
376    pub fn matches(&self, key: &KeyEvent) -> bool {
377        // Parse the key code
378        let code_matches = match self.code.to_lowercase().as_str() {
379            "esc" => matches!(key.code, KeyCode::Esc),
380            "enter" => matches!(key.code, KeyCode::Enter),
381            "backspace" => matches!(key.code, KeyCode::Backspace),
382            "tab" => matches!(key.code, KeyCode::Tab),
383            "space" => matches!(key.code, KeyCode::Char(' ')),
384            "f1" => matches!(key.code, KeyCode::F(1)),
385            "f2" => matches!(key.code, KeyCode::F(2)),
386            "f3" => matches!(key.code, KeyCode::F(3)),
387            "f4" => matches!(key.code, KeyCode::F(4)),
388            "f5" => matches!(key.code, KeyCode::F(5)),
389            "f6" => matches!(key.code, KeyCode::F(6)),
390            "f7" => matches!(key.code, KeyCode::F(7)),
391            "f8" => matches!(key.code, KeyCode::F(8)),
392            "f9" => matches!(key.code, KeyCode::F(9)),
393            "f10" => matches!(key.code, KeyCode::F(10)),
394            "f11" => matches!(key.code, KeyCode::F(11)),
395            "f12" => matches!(key.code, KeyCode::F(12)),
396            s if s.len() == 1 => {
397                if let Some(ch) = s.chars().next() {
398                    matches!(key.code, KeyCode::Char(c) if c.to_lowercase().to_string() == ch.to_lowercase().to_string())
399                } else {
400                    false
401                }
402            }
403            _ => false,
404        };
405
406        if !code_matches {
407            return false;
408        }
409
410        // Check modifiers
411        let mut expected_modifiers = KeyModifiers::empty();
412        for modifier in &self.modifiers {
413            match modifier.to_lowercase().as_str() {
414                "ctrl" | "control" => expected_modifiers |= KeyModifiers::CONTROL,
415                "shift" => expected_modifiers |= KeyModifiers::SHIFT,
416                "alt" => expected_modifiers |= KeyModifiers::ALT,
417                _ => {}
418            }
419        }
420
421        key.modifiers == expected_modifiers
422    }
423
424    /// Get a human-readable representation of the keybinding
425    pub fn display(&self) -> String {
426        let mut parts = Vec::new();
427
428        for modifier in &self.modifiers {
429            match modifier.to_lowercase().as_str() {
430                "ctrl" | "control" => parts.push("Ctrl".to_string()),
431                "shift" => parts.push("Shift".to_string()),
432                "alt" => parts.push("Alt".to_string()),
433                _ => parts.push(modifier.clone()),
434            }
435        }
436
437        parts.push(self.code.to_uppercase());
438        parts.join("+")
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_keybinding_matches() {
448        let kb = KeyBinding {
449            code: "q".to_string(),
450            modifiers: vec!["ctrl".to_string()],
451        };
452
453        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
454        assert!(kb.matches(&key));
455
456        let key2 = KeyEvent::new(
457            KeyCode::Char('q'),
458            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
459        );
460        assert!(!kb.matches(&key2));
461    }
462
463    #[test]
464    fn test_keybinding_display() {
465        let kb = KeyBinding {
466            code: "s".to_string(),
467            modifiers: vec!["ctrl".to_string()],
468        };
469        assert_eq!(kb.display(), "Ctrl+S");
470
471        let kb2 = KeyBinding {
472            code: "q".to_string(),
473            modifiers: vec!["ctrl".to_string()],
474        };
475        assert_eq!(kb2.display(), "Ctrl+Q");
476    }
477
478    #[test]
479    fn test_default_config() {
480        let config = Config::default();
481        assert_eq!(config.keybindings.save.code, "s");
482        assert_eq!(config.keybindings.quit.code, "q");
483        assert_eq!(config.keybindings.quit.modifiers.len(), 1);
484        assert_eq!(config.keybindings.quit_alt.code, "esc");
485        assert_eq!(config.keybindings.quit_alt.modifiers.len(), 0);
486    }
487
488    #[test]
489    fn test_lsp_config_default() {
490        let lsp_config = LspConfig::default();
491
492        // Should have default Rust and Python servers
493        assert!(lsp_config.servers.contains_key("rust"));
494        assert!(lsp_config.servers.contains_key("python"));
495
496        // Check Rust analyzer config
497        let rust_config = &lsp_config.servers["rust"];
498        assert_eq!(rust_config.command, "rust-analyzer");
499        assert!(rust_config.args.is_empty());
500        assert!(rust_config.enable_completion);
501        assert!(rust_config.enable_diagnostics);
502        assert!(rust_config.enable_goto_definition);
503
504        // Check Python config
505        let python_config = &lsp_config.servers["python"];
506        assert_eq!(python_config.command, "pyright-langserver");
507        assert_eq!(python_config.args, vec!["--stdio"]);
508        assert!(python_config.enable_completion);
509        assert!(python_config.enable_diagnostics);
510        assert!(python_config.enable_goto_definition);
511    }
512
513    #[test]
514    fn test_lsp_config_serialization() {
515        let mut servers = HashMap::new();
516        servers.insert(
517            "test".to_string(),
518            LspServerConfig {
519                command: "test-lsp".to_string(),
520                args: vec!["--test".to_string()],
521                embedded: false,
522                enable_completion: true,
523                enable_diagnostics: false,
524                enable_goto_definition: true,
525            },
526        );
527
528        let lsp_config = LspConfig { servers };
529
530        // Serialize to TOML
531        let toml_string = toml::to_string(&lsp_config).unwrap();
532
533        // Deserialize back
534        let deserialized: LspConfig = toml::from_str(&toml_string).unwrap();
535
536        assert!(deserialized.servers.contains_key("test"));
537        let test_config = &deserialized.servers["test"];
538        assert_eq!(test_config.command, "test-lsp");
539        assert_eq!(test_config.args, vec!["--test"]);
540        assert!(test_config.enable_completion);
541        assert!(!test_config.enable_diagnostics);
542        assert!(test_config.enable_goto_definition);
543    }
544
545    #[test]
546    fn test_config_with_lsp() {
547        let config = Config::default();
548
549        // Should have LSP config
550        assert!(!config.lsp.servers.is_empty());
551        assert!(config.lsp.servers.contains_key("rust"));
552    }
553
554    #[test]
555    fn test_lsp_server_config_defaults() {
556        // Test that serde defaults work correctly
557        let toml = r#"
558            command = "my-lsp"
559        "#;
560
561        let config: LspServerConfig = toml::from_str(toml).unwrap();
562        assert_eq!(config.command, "my-lsp");
563        assert!(config.args.is_empty());
564        assert!(config.enable_completion);
565        assert!(config.enable_diagnostics);
566        assert!(config.enable_goto_definition);
567    }
568}