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