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