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