1use 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
24const MAX_RULE_LIST_SIZE: usize = 100;
26
27const MAX_LINE_LENGTH: usize = 10_000;
29
30#[derive(Clone, Debug, PartialEq)]
32pub(crate) struct DocumentEntry {
33 pub(crate) content: String,
35 pub(crate) version: Option<i32>,
37 pub(crate) from_disk: bool,
39}
40
41#[derive(Clone, Debug)]
43pub(crate) struct ConfigCacheEntry {
44 pub(crate) config: Config,
46 pub(crate) config_file: Option<PathBuf>,
48 pub(crate) from_global_fallback: bool,
50}
51
52#[derive(Clone)]
62pub struct RumdlLanguageServer {
63 pub(crate) client: Client,
64 pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
66 pub(crate) rumdl_config: Arc<RwLock<Config>>,
68 pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
70 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
72 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
75 pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
77 pub(crate) index_state: Arc<RwLock<IndexState>>,
79 pub(crate) update_tx: mpsc::Sender<IndexUpdate>,
81 pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
84 pub(crate) client_supports_hierarchical_symbols: Arc<RwLock<bool>>,
88 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 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 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
111 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
112
113 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 pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
147 {
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 let Ok(path) = uri.to_file_path() {
157 if let Ok(content) = tokio::fs::read_to_string(&path).await {
158 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 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 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 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 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 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 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 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 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 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 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 let Some(text) = self.get_document_content(&uri).await else {
418 return Ok(None);
419 };
420
421 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, ¤t_text, start_col, position)
431 .await;
432 if !items.is_empty() {
433 return Ok(Some(CompletionResponse::Array(items)));
434 }
435 }
436
437 if self.config.read().await.enable_link_completions {
439 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 !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 let mut roots = self.workspace_roots.write().await;
488
489 for removed in ¶ms.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 for added in ¶ms.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 self.config_cache.write().await.clear();
510
511 self.reload_configuration().await;
513
514 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 let settings_value = params.settings;
527
528 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 let has_content_roots_key = matches!(
539 &rumdl_settings,
540 serde_json::Value::Object(obj) if obj.contains_key("linkCompletionContentRoots")
541 );
542
543 let mut config_applied = false;
545 let mut warnings: Vec<String> = Vec::new();
546
547 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 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 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 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 let mut config = self.config.write().await;
617
618 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 _ 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 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 for warning in &warnings {
715 log::warn!("{warning}");
716 }
717
718 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 self.config_cache.write().await.clear();
734
735 if config_applied {
743 self.load_configuration(false).await;
744
745 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 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 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 let _ = join_all(tasks).await;
772 }
773
774 async fn shutdown(&self) -> JsonRpcResult<()> {
775 log::info!("Shutting down rumdl Language Server");
776
777 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 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 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 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 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
855 return Ok(None);
856 };
857
858 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
860 Ok(Some(fixed_text)) => {
861 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 if let Some(entry) = self.documents.read().await.get(¶ms.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 self.documents.write().await.remove(¶ms.text_document.uri);
890
891 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 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 ¶ms.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 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 let mut cache = self.config_cache.write().await;
927 cache.clear();
928
929 drop(cache);
931 self.reload_configuration().await;
932 config_changed = true;
933 }
934
935 if let Some(ext) = path.extension()
937 && is_markdown_extension(ext)
938 {
939 match change.typ {
940 FileChangeType::CREATED | FileChangeType::CHANGED => {
941 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 let _ = self
959 .update_tx
960 .send(IndexUpdate::FileDeleted { path: path.clone() })
961 .await;
962 continue;
963 }
964 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 if config_changed {
989 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 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 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 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 let config_guard = self.config.read().await;
1092 let lsp_config = config_guard.clone();
1093 drop(config_guard);
1094
1095 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 self.rumdl_config.read().await.clone()
1102 };
1103
1104 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 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1116
1117 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1119
1120 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 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 result = Self::apply_formatting_options(result, &options);
1173
1174 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;