Skip to main content

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::path::PathBuf;
8use std::sync::Arc;
9
10use futures::future::join_all;
11use tokio::sync::{RwLock, mpsc};
12use tower_lsp::jsonrpc::Result as JsonRpcResult;
13use tower_lsp::lsp_types::*;
14use tower_lsp::{Client, LanguageServer};
15
16use crate::config::{Config, is_valid_rule_name};
17use crate::discovery::{ExcludeMatchers, is_markdown_extension};
18use crate::lsp::index_worker::IndexWorker;
19use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig};
20use crate::workspace_index::WorkspaceIndex;
21
22/// Maximum number of rules in enable/disable lists (DoS protection)
23const MAX_RULE_LIST_SIZE: usize = 100;
24
25/// Maximum allowed line length value (DoS protection)
26const MAX_LINE_LENGTH: usize = 10_000;
27
28/// Merge the keys present in a `workspace/didChangeConfiguration` payload onto the
29/// current LSP config, returning the merged config.
30///
31/// Only the keys the client actually sent are changed; every other field keeps its
32/// current value, so a partial payload (e.g. just `{"enableSymbols": false}`) never
33/// resets omitted fields to their defaults. A client that sends a full snapshot
34/// still fully applies. Returns `None` only if `incoming` is not a JSON object, the
35/// current config cannot be represented as one, or the merged object fails to
36/// deserialize -- all unreachable for the current field types, which round-trip
37/// through serde JSON; the caller treats `None` as "leave the config unchanged"
38/// rather than clobbering omitted fields.
39fn merge_lsp_config(current: &RumdlLspConfig, incoming: &serde_json::Value) -> Option<RumdlLspConfig> {
40    let serde_json::Value::Object(incoming) = incoming else {
41        return None;
42    };
43    let serde_json::Value::Object(mut base) = serde_json::to_value(current).ok()? else {
44        return None;
45    };
46    for (key, value) in incoming {
47        base.insert(key.clone(), value.clone());
48    }
49    serde_json::from_value(serde_json::Value::Object(base)).ok()
50}
51
52/// Represents a document in the LSP server's cache
53#[derive(Clone, Debug, PartialEq)]
54pub(crate) struct DocumentEntry {
55    /// The document content
56    pub(crate) content: String,
57    /// Version number from the editor (None for disk-loaded documents)
58    pub(crate) version: Option<i32>,
59    /// Whether the document was loaded from disk (true) or opened in editor (false)
60    pub(crate) from_disk: bool,
61}
62
63/// Cache entry for resolved configuration
64#[derive(Clone, Debug)]
65pub(crate) struct ConfigCacheEntry {
66    /// The resolved configuration
67    pub(crate) config: Config,
68    /// Config file path that was loaded (for invalidation)
69    pub(crate) config_file: Option<PathBuf>,
70    /// True if this entry came from the global/user fallback (no project config)
71    pub(crate) from_global_fallback: bool,
72}
73
74/// Main LSP server for rumdl
75///
76/// Following Ruff's pattern, this server provides:
77/// - Real-time diagnostics as users type
78/// - Code actions for automatic fixes
79/// - Configuration management
80/// - Multi-file support
81/// - Multi-root workspace support with per-file config resolution
82/// - Cross-file analysis with workspace indexing
83#[derive(Clone)]
84pub struct RumdlLanguageServer {
85    pub(crate) client: Client,
86    /// Configuration for the LSP server
87    pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
88    /// Rumdl core configuration (fallback/default)
89    pub(crate) rumdl_config: Arc<RwLock<Config>>,
90    /// Document store for open files and cached disk files
91    pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
92    /// Workspace root folders from the client
93    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
94    /// Configuration cache: maps directory path to resolved config
95    /// Key is the directory where config search started (file's parent dir)
96    pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
97    /// Workspace index for cross-file analysis (MD051)
98    pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
99    /// Current state of the workspace index (building/ready/error)
100    pub(crate) index_state: Arc<RwLock<IndexState>>,
101    /// Channel to send updates to the background index worker
102    pub(crate) update_tx: mpsc::Sender<IndexUpdate>,
103    /// Whether the client supports pull diagnostics (textDocument/diagnostic)
104    /// When true, we skip pushing diagnostics to avoid duplicates
105    pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
106    /// Whether the client supports hierarchical (nested) document symbols.
107    /// When false, `textDocument/documentSymbol` must return the flat
108    /// `SymbolInformation[]` form instead of a `DocumentSymbol` tree.
109    pub(crate) client_supports_hierarchical_symbols: Arc<RwLock<bool>>,
110    /// Config path supplied via `rumdl server --config <path>`.
111    ///
112    /// Held in an immutable field (not in `self.config`) so that client-driven
113    /// updates -- `initialize` initialization options or `workspace/didChangeConfiguration`
114    /// notifications -- cannot drop it. Treated as the highest-priority config source:
115    /// it outranks both client-supplied `configPath` and per-file discovery, mirroring
116    /// the CLI semantics where an explicit `--config` is standalone.
117    pub(crate) cli_config_path: Option<String>,
118}
119
120impl RumdlLanguageServer {
121    pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
122        let initial_config = RumdlLspConfig::default();
123        let cli_config_path = cli_config_path.map(str::to_string);
124
125        // Create shared state for workspace indexing
126        let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
127        let index_state = Arc::new(RwLock::new(IndexState::default()));
128        let workspace_roots = Arc::new(RwLock::new(Vec::new()));
129        let rumdl_config = Arc::new(RwLock::new(Config::default()));
130
131        // Create channels for index worker communication
132        let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
133        let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
134
135        // Spawn the background index worker
136        let worker = IndexWorker::new(
137            update_rx,
138            workspace_index.clone(),
139            index_state.clone(),
140            client.clone(),
141            workspace_roots.clone(),
142            relint_tx,
143            rumdl_config.clone(),
144        );
145        tokio::spawn(worker.run());
146
147        Self {
148            client,
149            config: Arc::new(RwLock::new(initial_config)),
150            rumdl_config,
151            documents: Arc::new(RwLock::new(HashMap::new())),
152            workspace_roots,
153            config_cache: Arc::new(RwLock::new(HashMap::new())),
154            workspace_index,
155            index_state,
156            update_tx,
157            client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
158            client_supports_hierarchical_symbols: Arc::new(RwLock::new(false)),
159            cli_config_path,
160        }
161    }
162
163    /// Get document content, either from cache or by reading from disk
164    ///
165    /// This method first checks if the document is in the cache (opened in editor).
166    /// If not found, it attempts to read the file from disk and caches it for
167    /// future requests.
168    pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
169        // First check the cache
170        {
171            let docs = self.documents.read().await;
172            if let Some(entry) = docs.get(uri) {
173                return Some(entry.content.clone());
174            }
175        }
176
177        // If not in cache and it's a file URI, try to read from disk
178        if let Ok(path) = uri.to_file_path() {
179            if let Ok(content) = tokio::fs::read_to_string(&path).await {
180                // Cache the document for future requests
181                let entry = DocumentEntry {
182                    content: content.clone(),
183                    version: None,
184                    from_disk: true,
185                };
186
187                let mut docs = self.documents.write().await;
188                docs.insert(uri.clone(), entry);
189
190                log::debug!("Loaded document from disk and cached: {uri}");
191                return Some(content);
192            } else {
193                log::debug!("Failed to read file from disk: {uri}");
194            }
195        }
196
197        None
198    }
199
200    /// Get document content only if the document is currently open in the editor.
201    ///
202    /// We intentionally do not read from disk here because diagnostics should be
203    /// scoped to open documents. This avoids lingering diagnostics after a file
204    /// is closed when clients use pull diagnostics.
205    async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
206        let docs = self.documents.read().await;
207        docs.get(uri)
208            .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
209    }
210
211    /// Resolve the Markdown flavor for a document, mirroring the per-file flavor
212    /// resolution used by diagnostics and formatting so symbol parsing matches.
213    pub(super) async fn resolve_flavor_for_uri(&self, uri: &Url) -> crate::config::MarkdownFlavor {
214        match uri.to_file_path() {
215            Ok(path) => self.resolve_config_for_file(&path).await.get_flavor_for_file(&path),
216            Err(_) => self.rumdl_config.read().await.markdown_flavor(),
217        }
218    }
219}
220
221#[tower_lsp::async_trait]
222impl LanguageServer for RumdlLanguageServer {
223    async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
224        log::info!("Initializing rumdl Language Server");
225
226        // Parse client capabilities and configuration
227        if let Some(options) = params.initialization_options
228            && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
229        {
230            *self.config.write().await = config;
231        }
232
233        // Detect if client supports pull diagnostics (textDocument/diagnostic)
234        // When the client supports pull, we avoid pushing to prevent duplicate diagnostics
235        let supports_pull = params
236            .capabilities
237            .text_document
238            .as_ref()
239            .and_then(|td| td.diagnostic.as_ref())
240            .is_some();
241
242        if supports_pull {
243            log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
244            *self.client_supports_pull_diagnostics.write().await = true;
245        } else {
246            log::info!("Client does not support pull diagnostics - using push model");
247        }
248
249        // Detect hierarchical document symbol support; without it the client expects
250        // the legacy flat `SymbolInformation[]` form.
251        let supports_hierarchical_symbols = params
252            .capabilities
253            .text_document
254            .as_ref()
255            .and_then(|td| td.document_symbol.as_ref())
256            .and_then(|ds| ds.hierarchical_document_symbol_support)
257            .unwrap_or(false);
258        *self.client_supports_hierarchical_symbols.write().await = supports_hierarchical_symbols;
259
260        // Extract and store workspace roots
261        let mut roots = Vec::new();
262        if let Some(workspace_folders) = params.workspace_folders {
263            for folder in workspace_folders {
264                if let Ok(path) = folder.uri.to_file_path() {
265                    let path = path.canonicalize().unwrap_or(path);
266                    log::info!("Workspace root: {}", path.display());
267                    roots.push(path);
268                }
269            }
270        } else if let Some(root_uri) = params.root_uri
271            && let Ok(path) = root_uri.to_file_path()
272        {
273            let path = path.canonicalize().unwrap_or(path);
274            log::info!("Workspace root: {}", path.display());
275            roots.push(path);
276        }
277        *self.workspace_roots.write().await = roots;
278
279        // Load rumdl configuration with auto-discovery (fallback/default)
280        self.load_configuration(false).await;
281
282        let (enable_link_navigation, enable_link_completions, enable_symbols) = {
283            let config = self.config.read().await;
284            (
285                config.enable_link_navigation,
286                config.enable_link_completions,
287                config.enable_symbols,
288            )
289        };
290
291        Ok(InitializeResult {
292            capabilities: ServerCapabilities {
293                text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
294                    open_close: Some(true),
295                    change: Some(TextDocumentSyncKind::FULL),
296                    will_save: Some(false),
297                    will_save_wait_until: Some(true),
298                    save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
299                        include_text: Some(false),
300                    })),
301                })),
302                code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
303                    code_action_kinds: Some(vec![
304                        CodeActionKind::QUICKFIX,
305                        CodeActionKind::SOURCE_FIX_ALL,
306                        CodeActionKind::new("source.fixAll.rumdl"),
307                    ]),
308                    work_done_progress_options: WorkDoneProgressOptions::default(),
309                    resolve_provider: None,
310                })),
311                document_formatting_provider: Some(OneOf::Left(true)),
312                document_range_formatting_provider: Some(OneOf::Left(true)),
313                document_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
314                workspace_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
315                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
316                    identifier: Some("rumdl".to_string()),
317                    inter_file_dependencies: true,
318                    workspace_diagnostics: false,
319                    work_done_progress_options: WorkDoneProgressOptions::default(),
320                })),
321                // Completion always stays available for fenced code-block language
322                // labels (backtick trigger). The link-target triggers (`(` `#` `/`
323                // `.` `-`) are only registered when link completions are enabled, so
324                // a client with its own link-completion source (e.g. a PKM-focused
325                // LSP) is not invoked on those characters when the feature is off.
326                completion_provider: Some(CompletionOptions {
327                    trigger_characters: Some(if enable_link_completions {
328                        vec![
329                            "`".to_string(),
330                            "(".to_string(),
331                            "#".to_string(),
332                            "/".to_string(),
333                            ".".to_string(),
334                            "-".to_string(),
335                        ]
336                    } else {
337                        vec!["`".to_string()]
338                    }),
339                    resolve_provider: Some(false),
340                    work_done_progress_options: WorkDoneProgressOptions::default(),
341                    all_commit_characters: None,
342                    completion_item: None,
343                }),
344                definition_provider: enable_link_navigation.then_some(OneOf::Left(true)),
345                references_provider: enable_link_navigation.then_some(OneOf::Left(true)),
346                hover_provider: enable_link_navigation.then_some(HoverProviderCapability::Simple(true)),
347                rename_provider: enable_link_navigation.then_some(OneOf::Right(RenameOptions {
348                    prepare_provider: Some(true),
349                    work_done_progress_options: WorkDoneProgressOptions::default(),
350                })),
351                workspace: Some(WorkspaceServerCapabilities {
352                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
353                        supported: Some(true),
354                        change_notifications: Some(OneOf::Left(true)),
355                    }),
356                    file_operations: None,
357                }),
358                ..Default::default()
359            },
360            server_info: Some(ServerInfo {
361                name: "rumdl".to_string(),
362                version: Some(env!("CARGO_PKG_VERSION").to_string()),
363            }),
364        })
365    }
366
367    async fn initialized(&self, _: InitializedParams) {
368        let version = env!("CARGO_PKG_VERSION");
369
370        // Get binary path and build time
371        let (binary_path, build_time) = std::env::current_exe().ok().map_or_else(
372            || ("unknown".to_string(), "unknown".to_string()),
373            |path| {
374                let path_str = path.to_str().unwrap_or("unknown").to_string();
375                let build_time = std::fs::metadata(&path)
376                    .ok()
377                    .and_then(|metadata| metadata.modified().ok())
378                    .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
379                    .and_then(|duration| {
380                        let secs = duration.as_secs();
381                        chrono::DateTime::from_timestamp(secs as i64, 0)
382                            .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
383                    })
384                    .unwrap_or_else(|| "unknown".to_string());
385                (path_str, build_time)
386            },
387        );
388
389        let working_dir = std::env::current_dir()
390            .ok()
391            .and_then(|p| p.to_str().map(std::string::ToString::to_string))
392            .unwrap_or_else(|| "unknown".to_string());
393
394        log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
395        log::info!("Working directory: {working_dir}");
396
397        self.client
398            .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
399            .await;
400
401        // Trigger initial workspace indexing for cross-file analysis
402        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
403            log::warn!("Failed to trigger initial workspace indexing");
404        } else {
405            log::info!("Triggered initial workspace indexing for cross-file analysis");
406        }
407
408        // Register file watchers for markdown files and config files
409        let markdown_patterns = [
410            "**/*.md",
411            "**/*.markdown",
412            "**/*.mdx",
413            "**/*.mkd",
414            "**/*.mkdn",
415            "**/*.mdown",
416            "**/*.mdwn",
417            "**/*.qmd",
418            "**/*.rmd",
419        ];
420        let config_patterns = [
421            "**/.rumdl.toml",
422            "**/rumdl.toml",
423            "**/pyproject.toml",
424            "**/.markdownlint.json",
425            "**/.markdownlint-cli2.yaml",
426            "**/.markdownlint-cli2.jsonc",
427        ];
428        let watchers: Vec<_> = markdown_patterns
429            .iter()
430            .chain(config_patterns.iter())
431            .map(|pattern| FileSystemWatcher {
432                glob_pattern: GlobPattern::String((*pattern).to_string()),
433                kind: Some(WatchKind::all()),
434            })
435            .collect();
436
437        let registration = Registration {
438            id: "markdown-watcher".to_string(),
439            method: "workspace/didChangeWatchedFiles".to_string(),
440            register_options: Some(
441                serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
442            ),
443        };
444
445        if self.client.register_capability(vec![registration]).await.is_err() {
446            log::debug!("Client does not support file watching capability");
447        }
448    }
449
450    async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
451        let uri = params.text_document_position.text_document.uri;
452        let position = params.text_document_position.position;
453
454        // Get document content
455        let Some(text) = self.get_document_content(&uri).await else {
456            return Ok(None);
457        };
458
459        // Code fence language completion (backtick trigger)
460        if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
461            log::debug!(
462                "Code fence completion triggered at {}:{}, current text: '{}'",
463                position.line,
464                position.character,
465                current_text
466            );
467            let items = self
468                .get_language_completions(&uri, &current_text, start_col, position)
469                .await;
470            if !items.is_empty() {
471                return Ok(Some(CompletionResponse::Array(items)));
472            }
473        }
474
475        // Link target completion: file paths and heading anchors
476        if self.config.read().await.enable_link_completions {
477            // For trigger characters that fire on many non-link contexts (`.`, `-`),
478            // skip the full parse when there is no `](` on the current line before
479            // the cursor.  This avoids needless work on list items and contractions.
480            let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
481            let skip_link_check = matches!(trigger, Some("." | "-")) && {
482                let line_num = position.line as usize;
483                // Scan the whole line — no byte-slicing at a UTF-16 offset needed.
484                // A line without `](` anywhere cannot contain a link target.
485                !text.lines().nth(line_num).is_some_and(|line| line.contains("]("))
486            };
487
488            if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
489                if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
490                    log::debug!(
491                        "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
492                        position.line,
493                        position.character,
494                        link_info.file_path,
495                        partial_anchor
496                    );
497                    let items = self
498                        .get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
499                        .await;
500                    if !items.is_empty() {
501                        return Ok(Some(CompletionResponse::Array(items)));
502                    }
503                } else {
504                    log::debug!(
505                        "File path completion triggered at {}:{}, partial: '{}'",
506                        position.line,
507                        position.character,
508                        link_info.file_path
509                    );
510                    let list = self
511                        .get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
512                        .await;
513                    if !list.items.is_empty() {
514                        return Ok(Some(CompletionResponse::List(list)));
515                    }
516                }
517            }
518        }
519
520        Ok(None)
521    }
522
523    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
524        // Update workspace roots
525        let mut roots = self.workspace_roots.write().await;
526
527        // Remove deleted workspace folders
528        for removed in &params.event.removed {
529            if let Ok(path) = removed.uri.to_file_path() {
530                roots.retain(|r| r != &path);
531                log::info!("Removed workspace root: {}", path.display());
532            }
533        }
534
535        // Add new workspace folders
536        for added in &params.event.added {
537            if let Ok(path) = added.uri.to_file_path()
538                && !roots.contains(&path)
539            {
540                log::info!("Added workspace root: {}", path.display());
541                roots.push(path);
542            }
543        }
544        drop(roots);
545
546        // Clear config cache as workspace structure changed
547        self.config_cache.write().await.clear();
548
549        // Reload fallback configuration
550        self.reload_configuration().await;
551
552        // Trigger full workspace rescan for cross-file index
553        if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
554            log::warn!("Failed to trigger workspace rescan after folder change");
555        }
556    }
557
558    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
559        log::debug!("Configuration changed: {:?}", params.settings);
560
561        // Parse settings from the notification
562        // Neovim sends: { "rumdl": { "MD013": {...}, ... } }
563        // VSCode might send the full RumdlLspConfig or similar structure
564        let settings_value = params.settings;
565
566        // Try to extract "rumdl" key from settings (Neovim style)
567        let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
568            obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
569        } else {
570            settings_value
571        };
572
573        // A settings payload that carries `linkCompletionContentRoots` is a full
574        // RumdlLspConfig even when the list is empty, so clearing it back to the
575        // workspace-root default applies instead of being treated as unknown.
576        let has_content_roots_key = matches!(
577            &rumdl_settings,
578            serde_json::Value::Object(obj) if obj.contains_key("linkCompletionContentRoots")
579        );
580
581        // `enableSymbols` is detected by key presence (not just a non-default value)
582        // so that a bare payload applies symmetrically: both `{"enableSymbols": false}`
583        // and a later `{"enableSymbols": true}` re-enable take effect, rather than the
584        // re-enable deserializing to the default and being dropped as an unknown key.
585        let has_symbols_key = matches!(
586            &rumdl_settings,
587            serde_json::Value::Object(obj) if obj.contains_key("enableSymbols")
588        );
589
590        // Track if we successfully applied any configuration
591        let mut config_applied = false;
592        let mut warnings: Vec<String> = Vec::new();
593
594        // Try to parse as LspRuleSettings first (Neovim style with "disable", "enable", rule keys)
595        // We check this first because RumdlLspConfig with #[serde(default)] will accept any JSON
596        // and just ignore unknown fields, which would lose the Neovim-style settings
597        if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
598            && (rule_settings.disable.is_some()
599                || rule_settings.enable.is_some()
600                || rule_settings.line_length.is_some()
601                || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
602        {
603            // Validate rule names in disable/enable lists
604            if let Some(ref disable) = rule_settings.disable {
605                for rule in disable {
606                    if !is_valid_rule_name(rule) {
607                        warnings.push(format!("Unknown rule in disable list: {rule}"));
608                    }
609                }
610            }
611            if let Some(ref enable) = rule_settings.enable {
612                for rule in enable {
613                    if !is_valid_rule_name(rule) {
614                        warnings.push(format!("Unknown rule in enable list: {rule}"));
615                    }
616                }
617            }
618            // Validate rule-specific settings
619            for rule_name in rule_settings.rules.keys() {
620                if !is_valid_rule_name(rule_name) {
621                    warnings.push(format!("Unknown rule in settings: {rule_name}"));
622                }
623            }
624
625            log::info!("Applied rule settings from configuration (Neovim style)");
626            let mut config = self.config.write().await;
627            config.settings = Some(rule_settings);
628            drop(config);
629            config_applied = true;
630        } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
631            && (full_config.config_path.is_some()
632                || full_config.enable_rules.is_some()
633                || full_config.disable_rules.is_some()
634                || full_config.settings.is_some()
635                || !full_config.enable_linting
636                || full_config.enable_auto_fix
637                || !full_config.enable_link_completions
638                || !full_config.enable_link_navigation
639                || has_symbols_key
640                || has_content_roots_key)
641        {
642            // Validate rule names
643            if let Some(ref rules) = full_config.enable_rules {
644                for rule in rules {
645                    if !is_valid_rule_name(rule) {
646                        warnings.push(format!("Unknown rule in enableRules: {rule}"));
647                    }
648                }
649            }
650            if let Some(ref rules) = full_config.disable_rules {
651                for rule in rules {
652                    if !is_valid_rule_name(rule) {
653                        warnings.push(format!("Unknown rule in disableRules: {rule}"));
654                    }
655                }
656            }
657
658            // Merge only the keys the client sent onto the current config (see
659            // `merge_lsp_config`), so a partial payload never clobbers previously-set
660            // fields. The write lock is held across the merge so the read-modify-write
661            // is atomic; the merge is synchronous and `.await`-free, so it cannot
662            // deadlock or stall the executor. `full_config` was already validated above
663            // and is no longer needed here (a merge failure leaves the config unchanged
664            // rather than falling back to a clobbering whole-struct replace).
665            {
666                let mut config = self.config.write().await;
667                if let Some(merged) = merge_lsp_config(&config, &rumdl_settings) {
668                    *config = merged;
669                    drop(config);
670                    log::info!("Merged LSP configuration from client settings");
671                    config_applied = true;
672                } else {
673                    drop(config);
674                    warnings.push("Could not merge LSP configuration update; keeping current settings".to_string());
675                }
676            }
677        } else if let serde_json::Value::Object(obj) = rumdl_settings {
678            // Otherwise, treat as per-rule settings with manual parsing
679            // Format: { "MD013": { "lineLength": 80 }, "disable": ["MD009"] }
680            let mut config = self.config.write().await;
681
682            // Manual parsing for Neovim format
683            let mut rules = std::collections::HashMap::new();
684            let mut disable = Vec::new();
685            let mut enable = Vec::new();
686            let mut line_length = None;
687
688            for (key, value) in obj {
689                match key.as_str() {
690                    "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
691                        Ok(d) => {
692                            if d.len() > MAX_RULE_LIST_SIZE {
693                                warnings.push(format!(
694                                    "Too many rules in 'disable' ({} > {}), truncating",
695                                    d.len(),
696                                    MAX_RULE_LIST_SIZE
697                                ));
698                            }
699                            for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
700                                if !is_valid_rule_name(rule) {
701                                    warnings.push(format!("Unknown rule in disable: {rule}"));
702                                }
703                            }
704                            disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
705                        }
706                        Err(_) => {
707                            warnings.push(format!(
708                                "Invalid 'disable' value: expected array of strings, got {value}"
709                            ));
710                        }
711                    },
712                    "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
713                        Ok(e) => {
714                            if e.len() > MAX_RULE_LIST_SIZE {
715                                warnings.push(format!(
716                                    "Too many rules in 'enable' ({} > {}), truncating",
717                                    e.len(),
718                                    MAX_RULE_LIST_SIZE
719                                ));
720                            }
721                            for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
722                                if !is_valid_rule_name(rule) {
723                                    warnings.push(format!("Unknown rule in enable: {rule}"));
724                                }
725                            }
726                            enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
727                        }
728                        Err(_) => {
729                            warnings.push(format!(
730                                "Invalid 'enable' value: expected array of strings, got {value}"
731                            ));
732                        }
733                    },
734                    "lineLength" | "line_length" | "line-length" => {
735                        if let Some(l) = value.as_u64() {
736                            match usize::try_from(l) {
737                                Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
738                                Ok(len) => warnings.push(format!(
739                                    "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
740                                )),
741                                Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
742                            }
743                        } else {
744                            warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
745                        }
746                    }
747                    // Rule-specific settings (e.g., "MD013": { "lineLength": 80 })
748                    _ if key.starts_with("MD") || key.starts_with("md") => {
749                        let normalized = key.to_uppercase();
750                        if !is_valid_rule_name(&normalized) {
751                            warnings.push(format!("Unknown rule: {key}"));
752                        }
753                        rules.insert(normalized, value);
754                    }
755                    _ => {
756                        // Unknown key - warn and ignore
757                        warnings.push(format!("Unknown configuration key: {key}"));
758                    }
759                }
760            }
761
762            let settings = LspRuleSettings {
763                line_length,
764                disable: if disable.is_empty() { None } else { Some(disable) },
765                enable: if enable.is_empty() { None } else { Some(enable) },
766                rules,
767            };
768
769            log::info!("Applied Neovim-style rule settings (manual parse)");
770            config.settings = Some(settings);
771            drop(config);
772            config_applied = true;
773        } else {
774            log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
775        }
776
777        // Log warnings for invalid configuration
778        for warning in &warnings {
779            log::warn!("{warning}");
780        }
781
782        // Notify client of configuration warnings via window/logMessage
783        if !warnings.is_empty() {
784            let message = if warnings.len() == 1 {
785                format!("rumdl: {}", warnings[0])
786            } else {
787                format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
788            };
789            self.client.log_message(MessageType::WARNING, message).await;
790        }
791
792        if !config_applied {
793            log::debug!("No configuration changes applied");
794        }
795
796        // Clear config cache to pick up new settings
797        self.config_cache.write().await.clear();
798
799        // Reload the global rumdl config so a runtime change to `configPath`
800        // (handled by the parser branches above) takes effect on the next
801        // resolve. Without this, `resolve_config_for_file` would keep returning
802        // the previously-loaded `rumdl_config`, silently ignoring the new path.
803        // Skip the client notification: the diagnostics refresh below already
804        // surfaces the result, and notifying here can stall when a test or
805        // misbehaving client isn't draining the LSP message channel.
806        if config_applied {
807            self.load_configuration(false).await;
808
809            // Rebuild the workspace index under the reloaded config: a new
810            // configPath can change exclude patterns or respect_gitignore,
811            // which the scan reads from the shared config.
812            if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
813                log::warn!("Failed to request workspace rescan after configuration change");
814            }
815        }
816
817        // Collect all open documents first (to avoid holding lock during async operations)
818        let doc_list: Vec<_> = {
819            let documents = self.documents.read().await;
820            documents
821                .iter()
822                .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
823                .collect()
824        };
825
826        // Refresh diagnostics for all open documents concurrently
827        let tasks = doc_list.into_iter().map(|(uri, text)| {
828            let server = self.clone();
829            tokio::spawn(async move {
830                server.update_diagnostics(uri, text, true).await;
831            })
832        });
833
834        // Wait for all diagnostics to complete
835        let _ = join_all(tasks).await;
836    }
837
838    async fn shutdown(&self) -> JsonRpcResult<()> {
839        log::info!("Shutting down rumdl Language Server");
840
841        // Signal the index worker to shut down
842        let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
843
844        Ok(())
845    }
846
847    async fn did_open(&self, params: DidOpenTextDocumentParams) {
848        let uri = params.text_document.uri;
849        let text = params.text_document.text;
850        let version = params.text_document.version;
851
852        let entry = DocumentEntry {
853            content: text.clone(),
854            version: Some(version),
855            from_disk: false,
856        };
857        self.documents.write().await.insert(uri.clone(), entry);
858
859        // Send update to index worker for cross-file analysis
860        if let Ok(path) = uri.to_file_path() {
861            let _ = self
862                .update_tx
863                .send(IndexUpdate::FileChanged {
864                    path,
865                    content: text.clone(),
866                })
867                .await;
868        }
869
870        self.update_diagnostics(uri, text, true).await;
871    }
872
873    async fn did_change(&self, params: DidChangeTextDocumentParams) {
874        let uri = params.text_document.uri;
875        let version = params.text_document.version;
876
877        if let Some(change) = params.content_changes.into_iter().next() {
878            let text = change.text;
879
880            let entry = DocumentEntry {
881                content: text.clone(),
882                version: Some(version),
883                from_disk: false,
884            };
885            self.documents.write().await.insert(uri.clone(), entry);
886
887            // Send update to index worker for cross-file analysis
888            if let Ok(path) = uri.to_file_path() {
889                let _ = self
890                    .update_tx
891                    .send(IndexUpdate::FileChanged {
892                        path,
893                        content: text.clone(),
894                    })
895                    .await;
896            }
897
898            self.update_diagnostics(uri, text, false).await;
899        }
900    }
901
902    async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
903        // Only apply fixes on manual saves (Cmd+S / Ctrl+S), not on autosave
904        // This respects VSCode's editor.formatOnSave: "explicit" setting
905        if params.reason != TextDocumentSaveReason::MANUAL {
906            return Ok(None);
907        }
908
909        let config_guard = self.config.read().await;
910        let enable_auto_fix = config_guard.enable_auto_fix;
911        drop(config_guard);
912
913        if !enable_auto_fix {
914            return Ok(None);
915        }
916
917        // Get the current document content
918        let Some(text) = self.get_document_content(&params.text_document.uri).await else {
919            return Ok(None);
920        };
921
922        // Apply all fixes
923        match self.apply_all_fixes(&params.text_document.uri, &text).await {
924            Ok(Some(fixed_text)) => {
925                // Return a single edit that replaces the entire document
926                Ok(Some(vec![TextEdit {
927                    range: Range {
928                        start: Position { line: 0, character: 0 },
929                        end: self.get_end_position(&text),
930                    },
931                    new_text: fixed_text,
932                }]))
933            }
934            Ok(None) => Ok(None),
935            Err(e) => {
936                log::error!("Failed to generate fixes in will_save_wait_until: {e}");
937                Ok(None)
938            }
939        }
940    }
941
942    async fn did_save(&self, params: DidSaveTextDocumentParams) {
943        // Re-lint the document after save
944        // Note: Auto-fixing is now handled by will_save_wait_until which runs before the save
945        if let Some(entry) = self.documents.read().await.get(&params.text_document.uri) {
946            self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
947                .await;
948        }
949    }
950
951    async fn did_close(&self, params: DidCloseTextDocumentParams) {
952        // Remove document from storage
953        self.documents.write().await.remove(&params.text_document.uri);
954
955        // Always clear diagnostics on close to ensure cleanup
956        // (Ruff does this unconditionally as a defensive measure)
957        self.client
958            .publish_diagnostics(params.text_document.uri, Vec::new(), None)
959            .await;
960    }
961
962    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
963        // Check if any of the changed files are config files
964        const CONFIG_FILES: &[&str] = &[
965            ".rumdl.toml",
966            "rumdl.toml",
967            "pyproject.toml",
968            ".markdownlint.json",
969            ".markdownlint-cli2.jsonc",
970            ".markdownlint-cli2.yaml",
971            ".markdownlint-cli2.yml",
972        ];
973
974        let mut config_changed = false;
975
976        for change in &params.changes {
977            if let Ok(path) = change.uri.to_file_path() {
978                let file_name = path.file_name().and_then(|f| f.to_str());
979
980                // Handle config file changes
981                if let Some(name) = file_name
982                    && CONFIG_FILES.contains(&name)
983                    && !config_changed
984                {
985                    log::info!("Config file changed: {}, invalidating config cache", path.display());
986
987                    // Clear the entire config cache when any config file changes.
988                    // Fallback entries (no config_file) become stale when a new config file
989                    // is created, and directory-scoped entries may resolve differently after edits.
990                    let mut cache = self.config_cache.write().await;
991                    cache.clear();
992
993                    // Also reload the global fallback configuration
994                    drop(cache);
995                    self.reload_configuration().await;
996                    config_changed = true;
997                }
998
999                // Handle markdown file changes for workspace index
1000                if let Some(ext) = path.extension()
1001                    && is_markdown_extension(ext)
1002                {
1003                    match change.typ {
1004                        FileChangeType::CREATED | FileChangeType::CHANGED => {
1005                            // Skip files the full scan would ignore (e.g. generated
1006                            // output) so filesystem-watch events don't reintroduce
1007                            // them. Explicitly opened/edited files bypass this via
1008                            // the did_open/did_change handlers.
1009                            let roots = self.workspace_roots.read().await.clone();
1010                            let (options, excludes) = {
1011                                let config = self.rumdl_config.read().await;
1012                                (
1013                                    crate::lsp::index_worker::index_walk_options(&config),
1014                                    ExcludeMatchers::new(&config.global.exclude),
1015                                )
1016                            };
1017                            if crate::lsp::index_worker::path_is_ignored_for_index(&roots, &path, &options, &excludes) {
1018                                // A file that was indexed before an ignore rule began
1019                                // matching it (e.g. just added to .gitignore) must be
1020                                // evicted so completions and navigation stop surfacing
1021                                // it. FileDeleted is a no-op when it was never indexed.
1022                                let _ = self
1023                                    .update_tx
1024                                    .send(IndexUpdate::FileDeleted { path: path.clone() })
1025                                    .await;
1026                                continue;
1027                            }
1028                            // Read file content and update index
1029                            if let Ok(content) = tokio::fs::read_to_string(&path).await {
1030                                let _ = self
1031                                    .update_tx
1032                                    .send(IndexUpdate::FileChanged {
1033                                        path: path.clone(),
1034                                        content,
1035                                    })
1036                                    .await;
1037                            }
1038                        }
1039                        FileChangeType::DELETED => {
1040                            let _ = self
1041                                .update_tx
1042                                .send(IndexUpdate::FileDeleted { path: path.clone() })
1043                                .await;
1044                        }
1045                        _ => {}
1046                    }
1047                }
1048            }
1049        }
1050
1051        // Re-lint all open documents if config changed
1052        if config_changed {
1053            // Rebuild the workspace index: discovery-relevant settings
1054            // (exclude patterns, respect_gitignore) may have changed, and the
1055            // scan reads them from the shared config.
1056            if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1057                log::warn!("Failed to request workspace rescan after config change");
1058            }
1059
1060            let docs_to_update: Vec<(Url, String)> = {
1061                let docs = self.documents.read().await;
1062                docs.iter()
1063                    .filter(|(_, entry)| !entry.from_disk)
1064                    .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1065                    .collect()
1066            };
1067
1068            for (uri, text) in docs_to_update {
1069                self.update_diagnostics(uri, text, true).await;
1070            }
1071        }
1072    }
1073
1074    async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1075        let uri = params.text_document.uri;
1076        let range = params.range;
1077        let requested_kinds = params.context.only;
1078
1079        if let Some(text) = self.get_document_content(&uri).await {
1080            match self.get_code_actions(&uri, &text, range).await {
1081                Ok(actions) => {
1082                    // Filter actions by requested kinds (if specified and non-empty)
1083                    // LSP spec: "If provided with no kinds, all supported kinds are returned"
1084                    // LSP code action kinds are hierarchical: source.fixAll.rumdl matches source.fixAll
1085                    let filtered_actions = if let Some(ref kinds) = requested_kinds
1086                        && !kinds.is_empty()
1087                    {
1088                        actions
1089                            .into_iter()
1090                            .filter(|action| {
1091                                action.kind.as_ref().is_some_and(|action_kind| {
1092                                    let action_kind_str = action_kind.as_str();
1093                                    kinds.iter().any(|requested| {
1094                                        let requested_str = requested.as_str();
1095                                        // Match if action kind starts with requested kind
1096                                        // e.g., "source.fixAll.rumdl" matches "source.fixAll"
1097                                        action_kind_str.starts_with(requested_str)
1098                                    })
1099                                })
1100                            })
1101                            .collect()
1102                    } else {
1103                        actions
1104                    };
1105
1106                    let response: Vec<CodeActionOrCommand> = filtered_actions
1107                        .into_iter()
1108                        .map(CodeActionOrCommand::CodeAction)
1109                        .collect();
1110                    Ok(Some(response))
1111                }
1112                Err(e) => {
1113                    log::error!("Failed to get code actions: {e}");
1114                    Ok(None)
1115                }
1116            }
1117        } else {
1118            Ok(None)
1119        }
1120    }
1121
1122    async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1123        // For markdown linting, we format the entire document because:
1124        // 1. Many markdown rules have document-wide implications (e.g., heading hierarchy, list consistency)
1125        // 2. Fixes often need surrounding context to be applied correctly
1126        // 3. This approach is common among linters (ESLint, rustfmt, etc. do similar)
1127        log::debug!(
1128            "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1129            params.range
1130        );
1131
1132        let formatting_params = DocumentFormattingParams {
1133            text_document: params.text_document,
1134            options: params.options,
1135            work_done_progress_params: params.work_done_progress_params,
1136        };
1137
1138        self.formatting(formatting_params).await
1139    }
1140
1141    async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1142        let uri = params.text_document.uri;
1143        let options = params.options;
1144
1145        log::debug!("Formatting request for: {uri}");
1146        log::debug!(
1147            "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1148            options.insert_final_newline,
1149            options.trim_final_newlines,
1150            options.trim_trailing_whitespace
1151        );
1152
1153        if let Some(text) = self.get_document_content(&uri).await {
1154            // Phase 1: Apply lint rule fixes, iterating to a fixpoint through the
1155            // same `FixCoordinator` engine as `rumdl check --fix` and the editor's
1156            // fix-all action. A single fix pass can leave cascading fixes
1157            // unapplied — e.g. MD030 widening a list marker, which then requires
1158            // MD007 to re-indent the nested content and its continuation lines —
1159            // which forced "Format Document" to be run several times to converge
1160            // (rvben/rumdl-vscode#145). `apply_all_fixes` also handles config
1161            // resolution, rule filtering, LSP overrides and excludes for the URI.
1162            let mut result = match self.apply_all_fixes(&uri, &text).await {
1163                Ok(Some(fixed)) => fixed,
1164                Ok(None) => text.clone(),
1165                Err(e) => {
1166                    log::error!("Failed to apply fixes during formatting: {e}");
1167                    text.clone()
1168                }
1169            };
1170
1171            // Phase 2: Apply FormattingOptions (standard LSP behavior)
1172            // This ensures we respect editor preferences even if lint rules don't catch everything
1173            result = Self::apply_formatting_options(result, &options);
1174
1175            // Return edit if content changed
1176            if result != text {
1177                log::debug!("Returning formatting edits");
1178                let end_position = self.get_end_position(&text);
1179                let edit = TextEdit {
1180                    range: Range {
1181                        start: Position { line: 0, character: 0 },
1182                        end: end_position,
1183                    },
1184                    new_text: result,
1185                };
1186                return Ok(Some(vec![edit]));
1187            }
1188
1189            Ok(Some(Vec::new()))
1190        } else {
1191            log::warn!("Document not found: {uri}");
1192            Ok(None)
1193        }
1194    }
1195
1196    async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1197        if !self.config.read().await.enable_link_navigation {
1198            return Ok(None);
1199        }
1200        let uri = params.text_document_position_params.text_document.uri;
1201        let position = params.text_document_position_params.position;
1202
1203        log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1204
1205        Ok(self.handle_goto_definition(&uri, position).await)
1206    }
1207
1208    async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1209        if !self.config.read().await.enable_link_navigation {
1210            return Ok(None);
1211        }
1212        let uri = params.text_document_position.text_document.uri;
1213        let position = params.text_document_position.position;
1214
1215        log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1216
1217        Ok(self.handle_references(&uri, position).await)
1218    }
1219
1220    async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1221        if !self.config.read().await.enable_link_navigation {
1222            return Ok(None);
1223        }
1224        let uri = params.text_document_position_params.text_document.uri;
1225        let position = params.text_document_position_params.position;
1226
1227        log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1228
1229        Ok(self.handle_hover(&uri, position).await)
1230    }
1231
1232    async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1233        if !self.config.read().await.enable_link_navigation {
1234            return Ok(None);
1235        }
1236        let uri = params.text_document.uri;
1237        let position = params.position;
1238
1239        log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1240
1241        Ok(self.handle_prepare_rename(&uri, position).await)
1242    }
1243
1244    async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1245        if !self.config.read().await.enable_link_navigation {
1246            return Ok(None);
1247        }
1248        let uri = params.text_document_position.text_document.uri;
1249        let position = params.text_document_position.position;
1250        let new_name = params.new_name;
1251
1252        log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1253
1254        Ok(self.handle_rename(&uri, position, &new_name).await)
1255    }
1256
1257    async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1258        let uri = params.text_document.uri;
1259
1260        if let Some(text) = self.get_open_document_content(&uri).await {
1261            match self.lint_document(&uri, &text, true).await {
1262                Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1263                    RelatedFullDocumentDiagnosticReport {
1264                        related_documents: None,
1265                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
1266                            result_id: None,
1267                            items: diagnostics,
1268                        },
1269                    },
1270                ))),
1271                Err(e) => {
1272                    log::error!("Failed to get diagnostics: {e}");
1273                    Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1274                        RelatedFullDocumentDiagnosticReport {
1275                            related_documents: None,
1276                            full_document_diagnostic_report: FullDocumentDiagnosticReport {
1277                                result_id: None,
1278                                items: Vec::new(),
1279                            },
1280                        },
1281                    )))
1282                }
1283            }
1284        } else {
1285            Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1286                RelatedFullDocumentDiagnosticReport {
1287                    related_documents: None,
1288                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
1289                        result_id: None,
1290                        items: Vec::new(),
1291                    },
1292                },
1293            )))
1294        }
1295    }
1296
1297    async fn document_symbol(&self, params: DocumentSymbolParams) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
1298        if !self.config.read().await.enable_symbols {
1299            return Ok(None);
1300        }
1301
1302        let uri = params.text_document.uri;
1303        let Some(text) = self.get_document_content(&uri).await else {
1304            return Ok(None);
1305        };
1306
1307        let flavor = self.resolve_flavor_for_uri(&uri).await;
1308        let ctx = crate::lint_context::LintContext::new(&text, flavor, None);
1309
1310        if *self.client_supports_hierarchical_symbols.read().await {
1311            let symbols = super::symbols::document_symbols(&ctx);
1312            Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Nested(symbols)))
1313        } else {
1314            let symbols = super::symbols::document_symbols_flat(&ctx, &uri);
1315            Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Flat(symbols)))
1316        }
1317    }
1318
1319    async fn symbol(&self, params: WorkspaceSymbolParams) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
1320        if !self.config.read().await.enable_symbols {
1321            return Ok(None);
1322        }
1323
1324        let query = params.query.to_lowercase();
1325        let index = self.workspace_index.read().await;
1326        let symbols = super::symbols::workspace_symbols(&index, &query);
1327        Ok(if symbols.is_empty() { None } else { Some(symbols) })
1328    }
1329}
1330
1331#[cfg(test)]
1332#[path = "tests.rs"]
1333mod tests;