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