rumdl_lib/lsp/
server.rs

1//! Main Language Server Protocol server implementation for rumdl
2//!
3//! This module implements the core LSP server following Ruff's architecture.
4//! It provides real-time markdown linting, diagnostics, and code actions.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use anyhow::Result;
10use tokio::sync::RwLock;
11use tower_lsp::jsonrpc::Result as JsonRpcResult;
12use tower_lsp::lsp_types::*;
13use tower_lsp::{Client, LanguageServer};
14
15use crate::config::Config;
16use crate::lsp::types::{RumdlLspConfig, warning_to_code_action, warning_to_diagnostic};
17use crate::rules;
18
19/// Main LSP server for rumdl
20///
21/// Following Ruff's pattern, this server provides:
22/// - Real-time diagnostics as users type
23/// - Code actions for automatic fixes
24/// - Configuration management
25/// - Multi-file support
26#[derive(Clone)]
27pub struct RumdlLanguageServer {
28    client: Client,
29    /// Configuration for the LSP server
30    config: Arc<RwLock<RumdlLspConfig>>,
31    /// Rumdl core configuration
32    rumdl_config: Arc<RwLock<Config>>,
33    /// Document store for open files
34    documents: Arc<RwLock<HashMap<Url, String>>>,
35}
36
37impl RumdlLanguageServer {
38    pub fn new(client: Client) -> Self {
39        Self {
40            client,
41            config: Arc::new(RwLock::new(RumdlLspConfig::default())),
42            rumdl_config: Arc::new(RwLock::new(Config::default())),
43            documents: Arc::new(RwLock::new(HashMap::new())),
44        }
45    }
46
47    /// Lint a document and return diagnostics
48    async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
49        let config_guard = self.config.read().await;
50
51        // Skip linting if disabled
52        if !config_guard.enable_linting {
53            return Ok(Vec::new());
54        }
55
56        drop(config_guard); // Release config lock early
57
58        // Get rumdl configuration
59        let rumdl_config = self.rumdl_config.read().await;
60        let all_rules = rules::all_rules(&rumdl_config);
61        drop(rumdl_config); // Release config lock early
62
63        // Run rumdl linting
64        match crate::lint(text, &all_rules, false) {
65            Ok(warnings) => {
66                let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
67                Ok(diagnostics)
68            }
69            Err(e) => {
70                log::error!("Failed to lint document {uri}: {e}");
71                Ok(Vec::new())
72            }
73        }
74    }
75
76    /// Update diagnostics for a document
77    async fn update_diagnostics(&self, uri: Url, text: String) {
78        match self.lint_document(&uri, &text).await {
79            Ok(diagnostics) => {
80                self.client.publish_diagnostics(uri, diagnostics, None).await;
81            }
82            Err(e) => {
83                log::error!("Failed to update diagnostics: {e}");
84            }
85        }
86    }
87
88    /// Apply all available fixes to a document
89    async fn apply_all_fixes(&self, _uri: &Url, text: &str) -> Result<Option<String>> {
90        let rumdl_config = self.rumdl_config.read().await;
91        let all_rules = rules::all_rules(&rumdl_config);
92        drop(rumdl_config);
93
94        // Apply fixes sequentially for each rule
95        let mut fixed_text = text.to_string();
96        let mut any_changes = false;
97
98        for rule in &all_rules {
99            let ctx = crate::lint_context::LintContext::new(&fixed_text);
100            match rule.fix(&ctx) {
101                Ok(new_text) => {
102                    if new_text != fixed_text {
103                        fixed_text = new_text;
104                        any_changes = true;
105                    }
106                }
107                Err(e) => {
108                    log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
109                }
110            }
111        }
112
113        if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
114    }
115
116    /// Get the end position of a document
117    fn get_end_position(&self, text: &str) -> Position {
118        let lines: Vec<&str> = text.lines().collect();
119        let line = lines.len().saturating_sub(1) as u32;
120        let character = lines.last().map_or(0, |l| l.len() as u32);
121        Position { line, character }
122    }
123
124    /// Get code actions for diagnostics at a position
125    async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
126        let rumdl_config = self.rumdl_config.read().await;
127        let all_rules = rules::all_rules(&rumdl_config);
128        drop(rumdl_config);
129
130        match crate::lint(text, &all_rules, false) {
131            Ok(warnings) => {
132                let mut actions = Vec::new();
133
134                for warning in warnings {
135                    // Check if warning is within the requested range
136                    let warning_line = (warning.line.saturating_sub(1)) as u32;
137                    if warning_line >= range.start.line
138                        && warning_line <= range.end.line
139                        && let Some(action) = warning_to_code_action(&warning, uri, text)
140                    {
141                        actions.push(action);
142                    }
143                }
144
145                Ok(actions)
146            }
147            Err(e) => {
148                log::error!("Failed to get code actions: {e}");
149                Ok(Vec::new())
150            }
151        }
152    }
153
154    /// Load or reload rumdl configuration from files
155    async fn load_configuration(&self, notify_client: bool) {
156        let config_guard = self.config.read().await;
157        let explicit_config_path = config_guard.config_path.clone();
158        drop(config_guard);
159
160        // Use the same discovery logic as CLI but with LSP-specific error handling
161        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
162            Ok(sourced_config) => {
163                let loaded_files = sourced_config.loaded_files.clone();
164                *self.rumdl_config.write().await = sourced_config.into();
165
166                if !loaded_files.is_empty() {
167                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
168                    log::info!("{message}");
169                    if notify_client {
170                        self.client.log_message(MessageType::INFO, &message).await;
171                    }
172                } else {
173                    log::info!("Using default rumdl configuration (no config files found)");
174                }
175            }
176            Err(e) => {
177                let message = format!("Failed to load rumdl config: {e}");
178                log::warn!("{message}");
179                if notify_client {
180                    self.client.log_message(MessageType::WARNING, &message).await;
181                }
182                // Use default configuration
183                *self.rumdl_config.write().await = crate::config::Config::default();
184            }
185        }
186    }
187
188    /// Reload rumdl configuration from files (with client notification)
189    async fn reload_configuration(&self) {
190        self.load_configuration(true).await;
191    }
192
193    /// Load configuration for LSP - similar to CLI loading but returns Result
194    fn load_config_for_lsp(
195        config_path: Option<&str>,
196    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
197        // Use the same configuration loading as the CLI
198        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
199    }
200}
201
202#[tower_lsp::async_trait]
203impl LanguageServer for RumdlLanguageServer {
204    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
205        log::info!("Initializing rumdl Language Server");
206
207        // Parse client capabilities and configuration
208        if let Some(options) = params.initialization_options
209            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
210        {
211            *self.config.write().await = config;
212        }
213
214        // Load rumdl configuration with auto-discovery
215        self.load_configuration(false).await;
216
217        Ok(InitializeResult {
218            capabilities: ServerCapabilities {
219                text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
220                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
221                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
222                    identifier: Some("rumdl".to_string()),
223                    inter_file_dependencies: false,
224                    workspace_diagnostics: false,
225                    work_done_progress_options: WorkDoneProgressOptions::default(),
226                })),
227                workspace: Some(WorkspaceServerCapabilities {
228                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
229                        supported: Some(true),
230                        change_notifications: Some(OneOf::Left(true)),
231                    }),
232                    file_operations: None,
233                }),
234                ..Default::default()
235            },
236            server_info: Some(ServerInfo {
237                name: "rumdl".to_string(),
238                version: Some(env!("CARGO_PKG_VERSION").to_string()),
239            }),
240        })
241    }
242
243    async fn initialized(&self, _: InitializedParams) {
244        log::info!("rumdl Language Server initialized");
245
246        self.client
247            .log_message(MessageType::INFO, "rumdl Language Server started")
248            .await;
249    }
250
251    async fn did_change_workspace_folders(&self, _params: DidChangeWorkspaceFoldersParams) {
252        // Reload configuration when workspace folders change
253        self.reload_configuration().await;
254    }
255
256    async fn shutdown(&self) -> JsonRpcResult<()> {
257        log::info!("Shutting down rumdl Language Server");
258        Ok(())
259    }
260
261    async fn did_open(&self, params: DidOpenTextDocumentParams) {
262        let uri = params.text_document.uri;
263        let text = params.text_document.text;
264
265        // Store document
266        self.documents.write().await.insert(uri.clone(), text.clone());
267
268        // Update diagnostics
269        self.update_diagnostics(uri, text).await;
270    }
271
272    async fn did_change(&self, params: DidChangeTextDocumentParams) {
273        let uri = params.text_document.uri;
274
275        // Apply changes (we're using FULL sync, so just take the full text)
276        if let Some(change) = params.content_changes.into_iter().next() {
277            let text = change.text;
278
279            // Update stored document
280            self.documents.write().await.insert(uri.clone(), text.clone());
281
282            // Update diagnostics
283            self.update_diagnostics(uri, text).await;
284        }
285    }
286
287    async fn did_save(&self, params: DidSaveTextDocumentParams) {
288        let config_guard = self.config.read().await;
289        let enable_auto_fix = config_guard.enable_auto_fix;
290        drop(config_guard);
291
292        // Auto-fix on save if enabled
293        if enable_auto_fix && let Some(text) = self.documents.read().await.get(&params.text_document.uri) {
294            match self.apply_all_fixes(&params.text_document.uri, text).await {
295                Ok(Some(fixed_text)) => {
296                    // Create a workspace edit to apply the fixes
297                    let edit = TextEdit {
298                        range: Range {
299                            start: Position { line: 0, character: 0 },
300                            end: self.get_end_position(text),
301                        },
302                        new_text: fixed_text.clone(),
303                    };
304
305                    let mut changes = std::collections::HashMap::new();
306                    changes.insert(params.text_document.uri.clone(), vec![edit]);
307
308                    let workspace_edit = WorkspaceEdit {
309                        changes: Some(changes),
310                        document_changes: None,
311                        change_annotations: None,
312                    };
313
314                    // Apply the edit
315                    match self.client.apply_edit(workspace_edit).await {
316                        Ok(response) => {
317                            if response.applied {
318                                log::info!("Auto-fix applied successfully");
319                                // Update our stored version
320                                self.documents
321                                    .write()
322                                    .await
323                                    .insert(params.text_document.uri.clone(), fixed_text);
324                            } else {
325                                log::warn!("Auto-fix was not applied: {:?}", response.failure_reason);
326                            }
327                        }
328                        Err(e) => {
329                            log::error!("Failed to apply auto-fix: {e}");
330                        }
331                    }
332                }
333                Ok(None) => {
334                    log::debug!("No fixes to apply");
335                }
336                Err(e) => {
337                    log::error!("Failed to generate fixes: {e}");
338                }
339            }
340        }
341
342        // Re-lint the document
343        if let Some(text) = self.documents.read().await.get(&params.text_document.uri) {
344            self.update_diagnostics(params.text_document.uri, text.clone()).await;
345        }
346    }
347
348    async fn did_close(&self, params: DidCloseTextDocumentParams) {
349        // Remove document from storage
350        self.documents.write().await.remove(&params.text_document.uri);
351
352        // Clear diagnostics
353        self.client
354            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
355            .await;
356    }
357
358    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
359        let uri = params.text_document.uri;
360        let range = params.range;
361
362        if let Some(text) = self.documents.read().await.get(&uri) {
363            match self.get_code_actions(&uri, text, range).await {
364                Ok(actions) => {
365                    let response: Vec<CodeActionOrCommand> =
366                        actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
367                    Ok(Some(response))
368                }
369                Err(e) => {
370                    log::error!("Failed to get code actions: {e}");
371                    Ok(None)
372                }
373            }
374        } else {
375            Ok(None)
376        }
377    }
378
379    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
380        let uri = params.text_document.uri;
381
382        if let Some(text) = self.documents.read().await.get(&uri) {
383            match self.lint_document(&uri, text).await {
384                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
385                    RelatedFullDocumentDiagnosticReport {
386                        related_documents: None,
387                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
388                            result_id: None,
389                            items: diagnostics,
390                        },
391                    },
392                ))),
393                Err(e) => {
394                    log::error!("Failed to get diagnostics: {e}");
395                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
396                        RelatedFullDocumentDiagnosticReport {
397                            related_documents: None,
398                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
399                                result_id: None,
400                                items: Vec::new(),
401                            },
402                        },
403                    )))
404                }
405            }
406        } else {
407            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
408                RelatedFullDocumentDiagnosticReport {
409                    related_documents: None,
410                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
411                        result_id: None,
412                        items: Vec::new(),
413                    },
414                },
415            )))
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::rule::LintWarning;
424    use tower_lsp::LspService;
425
426    fn create_test_server() -> RumdlLanguageServer {
427        let (service, _socket) = LspService::new(RumdlLanguageServer::new);
428        service.inner().clone()
429    }
430
431    #[tokio::test]
432    async fn test_server_creation() {
433        let server = create_test_server();
434
435        // Verify default configuration
436        let config = server.config.read().await;
437        assert!(config.enable_linting);
438        assert!(!config.enable_auto_fix);
439    }
440
441    #[tokio::test]
442    async fn test_lint_document() {
443        let server = create_test_server();
444
445        // Test linting with a simple markdown document
446        let uri = Url::parse("file:///test.md").unwrap();
447        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
448
449        let diagnostics = server.lint_document(&uri, text).await.unwrap();
450
451        // Should find trailing spaces violations
452        assert!(!diagnostics.is_empty());
453        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
454    }
455
456    #[tokio::test]
457    async fn test_lint_document_disabled() {
458        let server = create_test_server();
459
460        // Disable linting
461        server.config.write().await.enable_linting = false;
462
463        let uri = Url::parse("file:///test.md").unwrap();
464        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
465
466        let diagnostics = server.lint_document(&uri, text).await.unwrap();
467
468        // Should return empty diagnostics when disabled
469        assert!(diagnostics.is_empty());
470    }
471
472    #[tokio::test]
473    async fn test_get_code_actions() {
474        let server = create_test_server();
475
476        let uri = Url::parse("file:///test.md").unwrap();
477        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
478
479        // Create a range covering the whole document
480        let range = Range {
481            start: Position { line: 0, character: 0 },
482            end: Position { line: 3, character: 21 },
483        };
484
485        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
486
487        // Should have code actions for fixing trailing spaces
488        assert!(!actions.is_empty());
489        assert!(actions.iter().any(|a| a.title.contains("trailing")));
490    }
491
492    #[tokio::test]
493    async fn test_get_code_actions_outside_range() {
494        let server = create_test_server();
495
496        let uri = Url::parse("file:///test.md").unwrap();
497        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
498
499        // Create a range that doesn't cover the violations
500        let range = Range {
501            start: Position { line: 0, character: 0 },
502            end: Position { line: 0, character: 6 },
503        };
504
505        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
506
507        // Should have no code actions for this range
508        assert!(actions.is_empty());
509    }
510
511    #[tokio::test]
512    async fn test_document_storage() {
513        let server = create_test_server();
514
515        let uri = Url::parse("file:///test.md").unwrap();
516        let text = "# Test Document";
517
518        // Store document
519        server.documents.write().await.insert(uri.clone(), text.to_string());
520
521        // Verify storage
522        let stored = server.documents.read().await.get(&uri).cloned();
523        assert_eq!(stored, Some(text.to_string()));
524
525        // Remove document
526        server.documents.write().await.remove(&uri);
527
528        // Verify removal
529        let stored = server.documents.read().await.get(&uri).cloned();
530        assert_eq!(stored, None);
531    }
532
533    #[tokio::test]
534    async fn test_configuration_loading() {
535        let server = create_test_server();
536
537        // Load configuration with auto-discovery
538        server.load_configuration(false).await;
539
540        // Verify configuration was loaded successfully
541        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
542        let rumdl_config = server.rumdl_config.read().await;
543        // The loaded config is valid regardless of source
544        drop(rumdl_config); // Just verify we can access it without panic
545    }
546
547    #[tokio::test]
548    async fn test_load_config_for_lsp() {
549        // Test with no config file
550        let result = RumdlLanguageServer::load_config_for_lsp(None);
551        assert!(result.is_ok());
552
553        // Test with non-existent config file
554        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
555        assert!(result.is_err());
556    }
557
558    #[tokio::test]
559    async fn test_warning_conversion() {
560        let warning = LintWarning {
561            message: "Test warning".to_string(),
562            line: 1,
563            column: 1,
564            end_line: 1,
565            end_column: 10,
566            severity: crate::rule::Severity::Warning,
567            fix: None,
568            rule_name: Some("MD001"),
569        };
570
571        // Test diagnostic conversion
572        let diagnostic = warning_to_diagnostic(&warning);
573        assert_eq!(diagnostic.message, "Test warning");
574        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
575        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
576
577        // Test code action conversion (no fix)
578        let uri = Url::parse("file:///test.md").unwrap();
579        let action = warning_to_code_action(&warning, &uri, "Test content");
580        assert!(action.is_none());
581    }
582
583    #[tokio::test]
584    async fn test_multiple_documents() {
585        let server = create_test_server();
586
587        let uri1 = Url::parse("file:///test1.md").unwrap();
588        let uri2 = Url::parse("file:///test2.md").unwrap();
589        let text1 = "# Document 1";
590        let text2 = "# Document 2";
591
592        // Store multiple documents
593        {
594            let mut docs = server.documents.write().await;
595            docs.insert(uri1.clone(), text1.to_string());
596            docs.insert(uri2.clone(), text2.to_string());
597        }
598
599        // Verify both are stored
600        let docs = server.documents.read().await;
601        assert_eq!(docs.len(), 2);
602        assert_eq!(docs.get(&uri1).map(|s| s.as_str()), Some(text1));
603        assert_eq!(docs.get(&uri2).map(|s| s.as_str()), Some(text2));
604    }
605
606    #[tokio::test]
607    async fn test_auto_fix_on_save() {
608        let server = create_test_server();
609
610        // Enable auto-fix
611        {
612            let mut config = server.config.write().await;
613            config.enable_auto_fix = true;
614        }
615
616        let uri = Url::parse("file:///test.md").unwrap();
617        let text = "#Heading without space"; // MD018 violation
618
619        // Store document
620        server.documents.write().await.insert(uri.clone(), text.to_string());
621
622        // Test apply_all_fixes
623        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
624        assert!(fixed.is_some());
625        assert_eq!(fixed.unwrap(), "# Heading without space");
626    }
627
628    #[tokio::test]
629    async fn test_get_end_position() {
630        let server = create_test_server();
631
632        // Single line
633        let pos = server.get_end_position("Hello");
634        assert_eq!(pos.line, 0);
635        assert_eq!(pos.character, 5);
636
637        // Multiple lines
638        let pos = server.get_end_position("Hello\nWorld\nTest");
639        assert_eq!(pos.line, 2);
640        assert_eq!(pos.character, 4);
641
642        // Empty string
643        let pos = server.get_end_position("");
644        assert_eq!(pos.line, 0);
645        assert_eq!(pos.character, 0);
646
647        // Ends with newline - lines() doesn't include the empty line after \n
648        let pos = server.get_end_position("Hello\n");
649        assert_eq!(pos.line, 0);
650        assert_eq!(pos.character, 5);
651    }
652
653    #[tokio::test]
654    async fn test_empty_document_handling() {
655        let server = create_test_server();
656
657        let uri = Url::parse("file:///empty.md").unwrap();
658        let text = "";
659
660        // Test linting empty document
661        let diagnostics = server.lint_document(&uri, text).await.unwrap();
662        assert!(diagnostics.is_empty());
663
664        // Test code actions on empty document
665        let range = Range {
666            start: Position { line: 0, character: 0 },
667            end: Position { line: 0, character: 0 },
668        };
669        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
670        assert!(actions.is_empty());
671    }
672
673    #[tokio::test]
674    async fn test_config_update() {
675        let server = create_test_server();
676
677        // Update config
678        {
679            let mut config = server.config.write().await;
680            config.enable_auto_fix = true;
681            config.config_path = Some("/custom/path.toml".to_string());
682        }
683
684        // Verify update
685        let config = server.config.read().await;
686        assert!(config.enable_auto_fix);
687        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
688    }
689}