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::rule::Rule;
18use crate::rules;
19
20/// Main LSP server for rumdl
21///
22/// Following Ruff's pattern, this server provides:
23/// - Real-time diagnostics as users type
24/// - Code actions for automatic fixes
25/// - Configuration management
26/// - Multi-file support
27#[derive(Clone)]
28pub struct RumdlLanguageServer {
29    client: Client,
30    /// Configuration for the LSP server
31    config: Arc<RwLock<RumdlLspConfig>>,
32    /// Rumdl core configuration
33    rumdl_config: Arc<RwLock<Config>>,
34    /// Document store for open files
35    documents: Arc<RwLock<HashMap<Url, String>>>,
36}
37
38impl RumdlLanguageServer {
39    pub fn new(client: Client) -> Self {
40        Self {
41            client,
42            config: Arc::new(RwLock::new(RumdlLspConfig::default())),
43            rumdl_config: Arc::new(RwLock::new(Config::default())),
44            documents: Arc::new(RwLock::new(HashMap::new())),
45        }
46    }
47
48    /// Apply LSP config overrides to the filtered rules
49    fn apply_lsp_config_overrides(
50        &self,
51        mut filtered_rules: Vec<Box<dyn Rule>>,
52        lsp_config: &RumdlLspConfig,
53    ) -> Vec<Box<dyn Rule>> {
54        // Apply enable_rules override from LSP config (if specified, only these rules are active)
55        if let Some(enable) = &lsp_config.enable_rules
56            && !enable.is_empty()
57        {
58            let enable_set: std::collections::HashSet<String> = enable.iter().cloned().collect();
59            filtered_rules.retain(|rule| enable_set.contains(rule.name()));
60        }
61
62        // Apply disable_rules override from LSP config
63        if let Some(disable) = &lsp_config.disable_rules
64            && !disable.is_empty()
65        {
66            let disable_set: std::collections::HashSet<String> = disable.iter().cloned().collect();
67            filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
68        }
69
70        filtered_rules
71    }
72
73    /// Lint a document and return diagnostics
74    async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
75        let config_guard = self.config.read().await;
76
77        // Skip linting if disabled
78        if !config_guard.enable_linting {
79            return Ok(Vec::new());
80        }
81
82        let lsp_config = config_guard.clone();
83        drop(config_guard); // Release config lock early
84
85        // Get rumdl configuration
86        let rumdl_config = self.rumdl_config.read().await;
87        let all_rules = rules::all_rules(&rumdl_config);
88        let flavor = rumdl_config.markdown_flavor();
89
90        // Use the standard filter_rules function which respects config's disabled rules
91        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
92        drop(rumdl_config); // Release config lock early
93
94        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
95        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
96
97        // Run rumdl linting with the configured flavor
98        match crate::lint(text, &filtered_rules, false, flavor) {
99            Ok(warnings) => {
100                let diagnostics = warnings.iter().map(warning_to_diagnostic).collect();
101                Ok(diagnostics)
102            }
103            Err(e) => {
104                log::error!("Failed to lint document {uri}: {e}");
105                Ok(Vec::new())
106            }
107        }
108    }
109
110    /// Update diagnostics for a document
111    async fn update_diagnostics(&self, uri: Url, text: String) {
112        match self.lint_document(&uri, &text).await {
113            Ok(diagnostics) => {
114                self.client.publish_diagnostics(uri, diagnostics, None).await;
115            }
116            Err(e) => {
117                log::error!("Failed to update diagnostics: {e}");
118            }
119        }
120    }
121
122    /// Apply all available fixes to a document
123    async fn apply_all_fixes(&self, _uri: &Url, text: &str) -> Result<Option<String>> {
124        let config_guard = self.config.read().await;
125        let lsp_config = config_guard.clone();
126        drop(config_guard);
127
128        let rumdl_config = self.rumdl_config.read().await;
129        let all_rules = rules::all_rules(&rumdl_config);
130        let flavor = rumdl_config.markdown_flavor();
131
132        // Use the standard filter_rules function which respects config's disabled rules
133        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
134        drop(rumdl_config);
135
136        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
137        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
138
139        // Apply fixes sequentially for each rule
140        let mut fixed_text = text.to_string();
141        let mut any_changes = false;
142
143        for rule in &filtered_rules {
144            let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor);
145            match rule.fix(&ctx) {
146                Ok(new_text) => {
147                    if new_text != fixed_text {
148                        fixed_text = new_text;
149                        any_changes = true;
150                    }
151                }
152                Err(e) => {
153                    log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
154                }
155            }
156        }
157
158        if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
159    }
160
161    /// Get the end position of a document
162    fn get_end_position(&self, text: &str) -> Position {
163        let lines: Vec<&str> = text.lines().collect();
164        let line = lines.len().saturating_sub(1) as u32;
165        let character = lines.last().map_or(0, |l| l.len() as u32);
166        Position { line, character }
167    }
168
169    /// Get code actions for diagnostics at a position
170    async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
171        let config_guard = self.config.read().await;
172        let lsp_config = config_guard.clone();
173        drop(config_guard);
174
175        let rumdl_config = self.rumdl_config.read().await;
176        let all_rules = rules::all_rules(&rumdl_config);
177        let flavor = rumdl_config.markdown_flavor();
178
179        // Use the standard filter_rules function which respects config's disabled rules
180        let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
181        drop(rumdl_config);
182
183        // Apply LSP config overrides (select_rules, ignore_rules from VSCode settings)
184        filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
185
186        match crate::lint(text, &filtered_rules, false, flavor) {
187            Ok(warnings) => {
188                let mut actions = Vec::new();
189                let mut fixable_count = 0;
190
191                for warning in &warnings {
192                    // Check if warning is within the requested range
193                    let warning_line = (warning.line.saturating_sub(1)) as u32;
194                    if warning_line >= range.start.line
195                        && warning_line <= range.end.line
196                        && let Some(action) = warning_to_code_action(warning, uri, text)
197                    {
198                        actions.push(action);
199                        if warning.fix.is_some() {
200                            fixable_count += 1;
201                        }
202                    }
203                }
204
205                // Add "Fix all" action if there are multiple fixable issues in range
206                if fixable_count > 1 {
207                    // Count total fixable issues in the document
208                    let total_fixable = warnings.iter().filter(|w| w.fix.is_some()).count();
209
210                    if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &warnings)
211                        && fixed_content != text
212                    {
213                        // Calculate proper end position
214                        let mut line = 0u32;
215                        let mut character = 0u32;
216                        for ch in text.chars() {
217                            if ch == '\n' {
218                                line += 1;
219                                character = 0;
220                            } else {
221                                character += 1;
222                            }
223                        }
224
225                        let fix_all_action = CodeAction {
226                            title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
227                            kind: Some(CodeActionKind::QUICKFIX),
228                            diagnostics: Some(Vec::new()),
229                            edit: Some(WorkspaceEdit {
230                                changes: Some(
231                                    [(
232                                        uri.clone(),
233                                        vec![TextEdit {
234                                            range: Range {
235                                                start: Position { line: 0, character: 0 },
236                                                end: Position { line, character },
237                                            },
238                                            new_text: fixed_content,
239                                        }],
240                                    )]
241                                    .into_iter()
242                                    .collect(),
243                                ),
244                                ..Default::default()
245                            }),
246                            command: None,
247                            is_preferred: Some(true),
248                            disabled: None,
249                            data: None,
250                        };
251
252                        // Insert at the beginning to make it prominent
253                        actions.insert(0, fix_all_action);
254                    }
255                }
256
257                Ok(actions)
258            }
259            Err(e) => {
260                log::error!("Failed to get code actions: {e}");
261                Ok(Vec::new())
262            }
263        }
264    }
265
266    /// Load or reload rumdl configuration from files
267    async fn load_configuration(&self, notify_client: bool) {
268        let config_guard = self.config.read().await;
269        let explicit_config_path = config_guard.config_path.clone();
270        drop(config_guard);
271
272        // Use the same discovery logic as CLI but with LSP-specific error handling
273        match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
274            Ok(sourced_config) => {
275                let loaded_files = sourced_config.loaded_files.clone();
276                *self.rumdl_config.write().await = sourced_config.into();
277
278                if !loaded_files.is_empty() {
279                    let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
280                    log::info!("{message}");
281                    if notify_client {
282                        self.client.log_message(MessageType::INFO, &message).await;
283                    }
284                } else {
285                    log::info!("Using default rumdl configuration (no config files found)");
286                }
287            }
288            Err(e) => {
289                let message = format!("Failed to load rumdl config: {e}");
290                log::warn!("{message}");
291                if notify_client {
292                    self.client.log_message(MessageType::WARNING, &message).await;
293                }
294                // Use default configuration
295                *self.rumdl_config.write().await = crate::config::Config::default();
296            }
297        }
298    }
299
300    /// Reload rumdl configuration from files (with client notification)
301    async fn reload_configuration(&self) {
302        self.load_configuration(true).await;
303    }
304
305    /// Load configuration for LSP - similar to CLI loading but returns Result
306    fn load_config_for_lsp(
307        config_path: Option<&str>,
308    ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
309        // Use the same configuration loading as the CLI
310        crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
311    }
312}
313
314#[tower_lsp::async_trait]
315impl LanguageServer for RumdlLanguageServer {
316    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
317        log::info!("Initializing rumdl Language Server");
318
319        // Parse client capabilities and configuration
320        if let Some(options) = params.initialization_options
321            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
322        {
323            *self.config.write().await = config;
324        }
325
326        // Load rumdl configuration with auto-discovery
327        self.load_configuration(false).await;
328
329        Ok(InitializeResult {
330            capabilities: ServerCapabilities {
331                text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
332                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
333                document_formatting_provider: Some(OneOf::Left(true)),
334                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
335                    identifier: Some("rumdl".to_string()),
336                    inter_file_dependencies: false,
337                    workspace_diagnostics: false,
338                    work_done_progress_options: WorkDoneProgressOptions::default(),
339                })),
340                workspace: Some(WorkspaceServerCapabilities {
341                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
342                        supported: Some(true),
343                        change_notifications: Some(OneOf::Left(true)),
344                    }),
345                    file_operations: None,
346                }),
347                ..Default::default()
348            },
349            server_info: Some(ServerInfo {
350                name: "rumdl".to_string(),
351                version: Some(env!("CARGO_PKG_VERSION").to_string()),
352            }),
353        })
354    }
355
356    async fn initialized(&self, _: InitializedParams) {
357        log::info!("rumdl Language Server initialized");
358
359        self.client
360            .log_message(MessageType::INFO, "rumdl Language Server started")
361            .await;
362    }
363
364    async fn did_change_workspace_folders(&self, _params: DidChangeWorkspaceFoldersParams) {
365        // Reload configuration when workspace folders change
366        self.reload_configuration().await;
367    }
368
369    async fn shutdown(&self) -> JsonRpcResult<()> {
370        log::info!("Shutting down rumdl Language Server");
371        Ok(())
372    }
373
374    async fn did_open(&self, params: DidOpenTextDocumentParams) {
375        let uri = params.text_document.uri;
376        let text = params.text_document.text;
377
378        // Store document
379        self.documents.write().await.insert(uri.clone(), text.clone());
380
381        // Update diagnostics
382        self.update_diagnostics(uri, text).await;
383    }
384
385    async fn did_change(&self, params: DidChangeTextDocumentParams) {
386        let uri = params.text_document.uri;
387
388        // Apply changes (we're using FULL sync, so just take the full text)
389        if let Some(change) = params.content_changes.into_iter().next() {
390            let text = change.text;
391
392            // Update stored document
393            self.documents.write().await.insert(uri.clone(), text.clone());
394
395            // Update diagnostics
396            self.update_diagnostics(uri, text).await;
397        }
398    }
399
400    async fn did_save(&self, params: DidSaveTextDocumentParams) {
401        let config_guard = self.config.read().await;
402        let enable_auto_fix = config_guard.enable_auto_fix;
403        drop(config_guard);
404
405        // Auto-fix on save if enabled
406        if enable_auto_fix && let Some(text) = self.documents.read().await.get(&params.text_document.uri) {
407            match self.apply_all_fixes(&params.text_document.uri, text).await {
408                Ok(Some(fixed_text)) => {
409                    // Create a workspace edit to apply the fixes
410                    let edit = TextEdit {
411                        range: Range {
412                            start: Position { line: 0, character: 0 },
413                            end: self.get_end_position(text),
414                        },
415                        new_text: fixed_text.clone(),
416                    };
417
418                    let mut changes = std::collections::HashMap::new();
419                    changes.insert(params.text_document.uri.clone(), vec![edit]);
420
421                    let workspace_edit = WorkspaceEdit {
422                        changes: Some(changes),
423                        document_changes: None,
424                        change_annotations: None,
425                    };
426
427                    // Apply the edit
428                    match self.client.apply_edit(workspace_edit).await {
429                        Ok(response) => {
430                            if response.applied {
431                                log::info!("Auto-fix applied successfully");
432                                // Update our stored version
433                                self.documents
434                                    .write()
435                                    .await
436                                    .insert(params.text_document.uri.clone(), fixed_text);
437                            } else {
438                                log::warn!("Auto-fix was not applied: {:?}", response.failure_reason);
439                            }
440                        }
441                        Err(e) => {
442                            log::error!("Failed to apply auto-fix: {e}");
443                        }
444                    }
445                }
446                Ok(None) => {
447                    log::debug!("No fixes to apply");
448                }
449                Err(e) => {
450                    log::error!("Failed to generate fixes: {e}");
451                }
452            }
453        }
454
455        // Re-lint the document
456        if let Some(text) = self.documents.read().await.get(&params.text_document.uri) {
457            self.update_diagnostics(params.text_document.uri, text.clone()).await;
458        }
459    }
460
461    async fn did_close(&self, params: DidCloseTextDocumentParams) {
462        // Remove document from storage
463        self.documents.write().await.remove(&params.text_document.uri);
464
465        // Clear diagnostics
466        self.client
467            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
468            .await;
469    }
470
471    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
472        let uri = params.text_document.uri;
473        let range = params.range;
474
475        if let Some(text) = self.documents.read().await.get(&uri) {
476            match self.get_code_actions(&uri, text, range).await {
477                Ok(actions) => {
478                    let response: Vec<CodeActionOrCommand> =
479                        actions.into_iter().map(CodeActionOrCommand::CodeAction).collect();
480                    Ok(Some(response))
481                }
482                Err(e) => {
483                    log::error!("Failed to get code actions: {e}");
484                    Ok(None)
485                }
486            }
487        } else {
488            Ok(None)
489        }
490    }
491
492    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
493        let uri = params.text_document.uri;
494
495        if let Some(text) = self.documents.read().await.get(&uri) {
496            // Get all rules from config
497            let rumdl_config = self.rumdl_config.read().await;
498            let all_rules = rules::all_rules(&rumdl_config);
499            let flavor = rumdl_config.markdown_flavor();
500
501            // Use the standard filter_rules function which respects config's disabled rules
502            let filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
503            drop(rumdl_config);
504
505            // Lint the document to get all warnings
506            match crate::lint(text, &filtered_rules, false, flavor) {
507                Ok(warnings) => {
508                    // Check if there are any fixable warnings
509                    let has_fixes = warnings.iter().any(|w| w.fix.is_some());
510
511                    if has_fixes {
512                        // Apply fixes using the fix_utils function
513                        match crate::utils::fix_utils::apply_warning_fixes(text, &warnings) {
514                            Ok(fixed_content) => {
515                                // Only return edits if the content actually changed
516                                if fixed_content != *text {
517                                    // Create a single TextEdit that replaces the entire document
518                                    // Calculate proper end position by iterating through all characters
519                                    let mut line = 0u32;
520                                    let mut character = 0u32;
521
522                                    for ch in text.chars() {
523                                        if ch == '\n' {
524                                            line += 1;
525                                            character = 0;
526                                        } else {
527                                            character += 1;
528                                        }
529                                    }
530
531                                    let edit = TextEdit {
532                                        range: Range {
533                                            start: Position { line: 0, character: 0 },
534                                            end: Position { line, character },
535                                        },
536                                        new_text: fixed_content,
537                                    };
538
539                                    return Ok(Some(vec![edit]));
540                                }
541                            }
542                            Err(e) => {
543                                log::error!("Failed to apply fixes: {e}");
544                            }
545                        }
546                    }
547
548                    // No fixes available or applied
549                    Ok(None)
550                }
551                Err(e) => {
552                    log::error!("Failed to format document: {e}");
553                    Ok(None)
554                }
555            }
556        } else {
557            Ok(None)
558        }
559    }
560
561    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
562        let uri = params.text_document.uri;
563
564        if let Some(text) = self.documents.read().await.get(&uri) {
565            match self.lint_document(&uri, text).await {
566                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
567                    RelatedFullDocumentDiagnosticReport {
568                        related_documents: None,
569                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
570                            result_id: None,
571                            items: diagnostics,
572                        },
573                    },
574                ))),
575                Err(e) => {
576                    log::error!("Failed to get diagnostics: {e}");
577                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
578                        RelatedFullDocumentDiagnosticReport {
579                            related_documents: None,
580                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
581                                result_id: None,
582                                items: Vec::new(),
583                            },
584                        },
585                    )))
586                }
587            }
588        } else {
589            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
590                RelatedFullDocumentDiagnosticReport {
591                    related_documents: None,
592                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
593                        result_id: None,
594                        items: Vec::new(),
595                    },
596                },
597            )))
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::rule::LintWarning;
606    use tower_lsp::LspService;
607
608    fn create_test_server() -> RumdlLanguageServer {
609        let (service, _socket) = LspService::new(RumdlLanguageServer::new);
610        service.inner().clone()
611    }
612
613    #[tokio::test]
614    async fn test_server_creation() {
615        let server = create_test_server();
616
617        // Verify default configuration
618        let config = server.config.read().await;
619        assert!(config.enable_linting);
620        assert!(!config.enable_auto_fix);
621    }
622
623    #[tokio::test]
624    async fn test_lint_document() {
625        let server = create_test_server();
626
627        // Test linting with a simple markdown document
628        let uri = Url::parse("file:///test.md").unwrap();
629        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
630
631        let diagnostics = server.lint_document(&uri, text).await.unwrap();
632
633        // Should find trailing spaces violations
634        assert!(!diagnostics.is_empty());
635        assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
636    }
637
638    #[tokio::test]
639    async fn test_lint_document_disabled() {
640        let server = create_test_server();
641
642        // Disable linting
643        server.config.write().await.enable_linting = false;
644
645        let uri = Url::parse("file:///test.md").unwrap();
646        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
647
648        let diagnostics = server.lint_document(&uri, text).await.unwrap();
649
650        // Should return empty diagnostics when disabled
651        assert!(diagnostics.is_empty());
652    }
653
654    #[tokio::test]
655    async fn test_get_code_actions() {
656        let server = create_test_server();
657
658        let uri = Url::parse("file:///test.md").unwrap();
659        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
660
661        // Create a range covering the whole document
662        let range = Range {
663            start: Position { line: 0, character: 0 },
664            end: Position { line: 3, character: 21 },
665        };
666
667        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
668
669        // Should have code actions for fixing trailing spaces
670        assert!(!actions.is_empty());
671        assert!(actions.iter().any(|a| a.title.contains("trailing")));
672    }
673
674    #[tokio::test]
675    async fn test_get_code_actions_outside_range() {
676        let server = create_test_server();
677
678        let uri = Url::parse("file:///test.md").unwrap();
679        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
680
681        // Create a range that doesn't cover the violations
682        let range = Range {
683            start: Position { line: 0, character: 0 },
684            end: Position { line: 0, character: 6 },
685        };
686
687        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
688
689        // Should have no code actions for this range
690        assert!(actions.is_empty());
691    }
692
693    #[tokio::test]
694    async fn test_document_storage() {
695        let server = create_test_server();
696
697        let uri = Url::parse("file:///test.md").unwrap();
698        let text = "# Test Document";
699
700        // Store document
701        server.documents.write().await.insert(uri.clone(), text.to_string());
702
703        // Verify storage
704        let stored = server.documents.read().await.get(&uri).cloned();
705        assert_eq!(stored, Some(text.to_string()));
706
707        // Remove document
708        server.documents.write().await.remove(&uri);
709
710        // Verify removal
711        let stored = server.documents.read().await.get(&uri).cloned();
712        assert_eq!(stored, None);
713    }
714
715    #[tokio::test]
716    async fn test_configuration_loading() {
717        let server = create_test_server();
718
719        // Load configuration with auto-discovery
720        server.load_configuration(false).await;
721
722        // Verify configuration was loaded successfully
723        // The config could be from: .rumdl.toml, pyproject.toml, .markdownlint.json, or default
724        let rumdl_config = server.rumdl_config.read().await;
725        // The loaded config is valid regardless of source
726        drop(rumdl_config); // Just verify we can access it without panic
727    }
728
729    #[tokio::test]
730    async fn test_load_config_for_lsp() {
731        // Test with no config file
732        let result = RumdlLanguageServer::load_config_for_lsp(None);
733        assert!(result.is_ok());
734
735        // Test with non-existent config file
736        let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
737        assert!(result.is_err());
738    }
739
740    #[tokio::test]
741    async fn test_warning_conversion() {
742        let warning = LintWarning {
743            message: "Test warning".to_string(),
744            line: 1,
745            column: 1,
746            end_line: 1,
747            end_column: 10,
748            severity: crate::rule::Severity::Warning,
749            fix: None,
750            rule_name: Some("MD001"),
751        };
752
753        // Test diagnostic conversion
754        let diagnostic = warning_to_diagnostic(&warning);
755        assert_eq!(diagnostic.message, "Test warning");
756        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
757        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
758
759        // Test code action conversion (no fix)
760        let uri = Url::parse("file:///test.md").unwrap();
761        let action = warning_to_code_action(&warning, &uri, "Test content");
762        assert!(action.is_none());
763    }
764
765    #[tokio::test]
766    async fn test_multiple_documents() {
767        let server = create_test_server();
768
769        let uri1 = Url::parse("file:///test1.md").unwrap();
770        let uri2 = Url::parse("file:///test2.md").unwrap();
771        let text1 = "# Document 1";
772        let text2 = "# Document 2";
773
774        // Store multiple documents
775        {
776            let mut docs = server.documents.write().await;
777            docs.insert(uri1.clone(), text1.to_string());
778            docs.insert(uri2.clone(), text2.to_string());
779        }
780
781        // Verify both are stored
782        let docs = server.documents.read().await;
783        assert_eq!(docs.len(), 2);
784        assert_eq!(docs.get(&uri1).map(|s| s.as_str()), Some(text1));
785        assert_eq!(docs.get(&uri2).map(|s| s.as_str()), Some(text2));
786    }
787
788    #[tokio::test]
789    async fn test_auto_fix_on_save() {
790        let server = create_test_server();
791
792        // Enable auto-fix
793        {
794            let mut config = server.config.write().await;
795            config.enable_auto_fix = true;
796        }
797
798        let uri = Url::parse("file:///test.md").unwrap();
799        let text = "#Heading without space"; // MD018 violation
800
801        // Store document
802        server.documents.write().await.insert(uri.clone(), text.to_string());
803
804        // Test apply_all_fixes
805        let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
806        assert!(fixed.is_some());
807        assert_eq!(fixed.unwrap(), "# Heading without space");
808    }
809
810    #[tokio::test]
811    async fn test_get_end_position() {
812        let server = create_test_server();
813
814        // Single line
815        let pos = server.get_end_position("Hello");
816        assert_eq!(pos.line, 0);
817        assert_eq!(pos.character, 5);
818
819        // Multiple lines
820        let pos = server.get_end_position("Hello\nWorld\nTest");
821        assert_eq!(pos.line, 2);
822        assert_eq!(pos.character, 4);
823
824        // Empty string
825        let pos = server.get_end_position("");
826        assert_eq!(pos.line, 0);
827        assert_eq!(pos.character, 0);
828
829        // Ends with newline - lines() doesn't include the empty line after \n
830        let pos = server.get_end_position("Hello\n");
831        assert_eq!(pos.line, 0);
832        assert_eq!(pos.character, 5);
833    }
834
835    #[tokio::test]
836    async fn test_empty_document_handling() {
837        let server = create_test_server();
838
839        let uri = Url::parse("file:///empty.md").unwrap();
840        let text = "";
841
842        // Test linting empty document
843        let diagnostics = server.lint_document(&uri, text).await.unwrap();
844        assert!(diagnostics.is_empty());
845
846        // Test code actions on empty document
847        let range = Range {
848            start: Position { line: 0, character: 0 },
849            end: Position { line: 0, character: 0 },
850        };
851        let actions = server.get_code_actions(&uri, text, range).await.unwrap();
852        assert!(actions.is_empty());
853    }
854
855    #[tokio::test]
856    async fn test_config_update() {
857        let server = create_test_server();
858
859        // Update config
860        {
861            let mut config = server.config.write().await;
862            config.enable_auto_fix = true;
863            config.config_path = Some("/custom/path.toml".to_string());
864        }
865
866        // Verify update
867        let config = server.config.read().await;
868        assert!(config.enable_auto_fix);
869        assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
870    }
871
872    #[tokio::test]
873    async fn test_document_formatting() {
874        let server = create_test_server();
875        let uri = Url::parse("file:///test.md").unwrap();
876        let text = "# Test\n\nThis is a test  \nWith trailing spaces  ";
877
878        // Store document
879        server.documents.write().await.insert(uri.clone(), text.to_string());
880
881        // Create formatting params
882        let params = DocumentFormattingParams {
883            text_document: TextDocumentIdentifier { uri: uri.clone() },
884            options: FormattingOptions {
885                tab_size: 4,
886                insert_spaces: true,
887                properties: HashMap::new(),
888                trim_trailing_whitespace: Some(true),
889                insert_final_newline: Some(true),
890                trim_final_newlines: Some(true),
891            },
892            work_done_progress_params: WorkDoneProgressParams::default(),
893        };
894
895        // Call formatting
896        let result = server.formatting(params).await.unwrap();
897
898        // Should return text edits that fix the trailing spaces
899        assert!(result.is_some());
900        let edits = result.unwrap();
901        assert!(!edits.is_empty());
902
903        // The new text should have trailing spaces removed
904        let edit = &edits[0];
905        // The formatted text should have the trailing spaces removed from the middle line
906        // and a final newline added
907        let expected = "# Test\n\nThis is a test  \nWith trailing spaces\n";
908        assert_eq!(edit.new_text, expected);
909    }
910}