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