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::lsp::index_worker::IndexWorker;
18use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig};
19use crate::rule::FixCapability;
20use crate::rules;
21use crate::workspace_index::WorkspaceIndex;
22
23const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
25
26const MAX_RULE_LIST_SIZE: usize = 100;
28
29const MAX_LINE_LENGTH: usize = 10_000;
31
32#[inline]
34fn is_markdown_extension(ext: &str) -> bool {
35 MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
36}
37
38#[derive(Clone, Debug, PartialEq)]
40pub(crate) struct DocumentEntry {
41 pub(crate) content: String,
43 pub(crate) version: Option<i32>,
45 pub(crate) from_disk: bool,
47}
48
49#[derive(Clone, Debug)]
51pub(crate) struct ConfigCacheEntry {
52 pub(crate) config: Config,
54 pub(crate) config_file: Option<PathBuf>,
56 pub(crate) from_global_fallback: bool,
58}
59
60#[derive(Clone)]
70pub struct RumdlLanguageServer {
71 pub(crate) client: Client,
72 pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
74 pub(crate) rumdl_config: Arc<RwLock<Config>>,
76 pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
78 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
80 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
83 pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
85 pub(crate) index_state: Arc<RwLock<IndexState>>,
87 pub(crate) update_tx: mpsc::Sender<IndexUpdate>,
89 pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
92 pub(crate) cli_config_path: Option<String>,
100}
101
102impl RumdlLanguageServer {
103 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
104 let initial_config = RumdlLspConfig::default();
105 let cli_config_path = cli_config_path.map(str::to_string);
106
107 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
109 let index_state = Arc::new(RwLock::new(IndexState::default()));
110 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
111
112 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
114 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
115
116 let worker = IndexWorker::new(
118 update_rx,
119 workspace_index.clone(),
120 index_state.clone(),
121 client.clone(),
122 workspace_roots.clone(),
123 relint_tx,
124 );
125 tokio::spawn(worker.run());
126
127 Self {
128 client,
129 config: Arc::new(RwLock::new(initial_config)),
130 rumdl_config: Arc::new(RwLock::new(Config::default())),
131 documents: Arc::new(RwLock::new(HashMap::new())),
132 workspace_roots,
133 config_cache: Arc::new(RwLock::new(HashMap::new())),
134 workspace_index,
135 index_state,
136 update_tx,
137 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
138 cli_config_path,
139 }
140 }
141
142 pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
148 {
150 let docs = self.documents.read().await;
151 if let Some(entry) = docs.get(uri) {
152 return Some(entry.content.clone());
153 }
154 }
155
156 if let Ok(path) = uri.to_file_path() {
158 if let Ok(content) = tokio::fs::read_to_string(&path).await {
159 let entry = DocumentEntry {
161 content: content.clone(),
162 version: None,
163 from_disk: true,
164 };
165
166 let mut docs = self.documents.write().await;
167 docs.insert(uri.clone(), entry);
168
169 log::debug!("Loaded document from disk and cached: {uri}");
170 return Some(content);
171 } else {
172 log::debug!("Failed to read file from disk: {uri}");
173 }
174 }
175
176 None
177 }
178
179 async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
185 let docs = self.documents.read().await;
186 docs.get(uri)
187 .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
188 }
189}
190
191#[tower_lsp::async_trait]
192impl LanguageServer for RumdlLanguageServer {
193 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
194 log::info!("Initializing rumdl Language Server");
195
196 if let Some(options) = params.initialization_options
198 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
199 {
200 *self.config.write().await = config;
201 }
202
203 let supports_pull = params
206 .capabilities
207 .text_document
208 .as_ref()
209 .and_then(|td| td.diagnostic.as_ref())
210 .is_some();
211
212 if supports_pull {
213 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
214 *self.client_supports_pull_diagnostics.write().await = true;
215 } else {
216 log::info!("Client does not support pull diagnostics - using push model");
217 }
218
219 let mut roots = Vec::new();
221 if let Some(workspace_folders) = params.workspace_folders {
222 for folder in workspace_folders {
223 if let Ok(path) = folder.uri.to_file_path() {
224 let path = path.canonicalize().unwrap_or(path);
225 log::info!("Workspace root: {}", path.display());
226 roots.push(path);
227 }
228 }
229 } else if let Some(root_uri) = params.root_uri
230 && let Ok(path) = root_uri.to_file_path()
231 {
232 let path = path.canonicalize().unwrap_or(path);
233 log::info!("Workspace root: {}", path.display());
234 roots.push(path);
235 }
236 *self.workspace_roots.write().await = roots;
237
238 self.load_configuration(false).await;
240
241 let enable_link_navigation = self.config.read().await.enable_link_navigation;
242
243 Ok(InitializeResult {
244 capabilities: ServerCapabilities {
245 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
246 open_close: Some(true),
247 change: Some(TextDocumentSyncKind::FULL),
248 will_save: Some(false),
249 will_save_wait_until: Some(true),
250 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
251 include_text: Some(false),
252 })),
253 })),
254 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
255 code_action_kinds: Some(vec![
256 CodeActionKind::QUICKFIX,
257 CodeActionKind::SOURCE_FIX_ALL,
258 CodeActionKind::new("source.fixAll.rumdl"),
259 ]),
260 work_done_progress_options: WorkDoneProgressOptions::default(),
261 resolve_provider: None,
262 })),
263 document_formatting_provider: Some(OneOf::Left(true)),
264 document_range_formatting_provider: Some(OneOf::Left(true)),
265 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
266 identifier: Some("rumdl".to_string()),
267 inter_file_dependencies: true,
268 workspace_diagnostics: false,
269 work_done_progress_options: WorkDoneProgressOptions::default(),
270 })),
271 completion_provider: Some(CompletionOptions {
272 trigger_characters: Some(vec![
273 "`".to_string(),
274 "(".to_string(),
275 "#".to_string(),
276 "/".to_string(),
277 ".".to_string(),
278 "-".to_string(),
279 ]),
280 resolve_provider: Some(false),
281 work_done_progress_options: WorkDoneProgressOptions::default(),
282 all_commit_characters: None,
283 completion_item: None,
284 }),
285 definition_provider: enable_link_navigation.then_some(OneOf::Left(true)),
286 references_provider: enable_link_navigation.then_some(OneOf::Left(true)),
287 hover_provider: enable_link_navigation.then_some(HoverProviderCapability::Simple(true)),
288 rename_provider: enable_link_navigation.then_some(OneOf::Right(RenameOptions {
289 prepare_provider: Some(true),
290 work_done_progress_options: WorkDoneProgressOptions::default(),
291 })),
292 workspace: Some(WorkspaceServerCapabilities {
293 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
294 supported: Some(true),
295 change_notifications: Some(OneOf::Left(true)),
296 }),
297 file_operations: None,
298 }),
299 ..Default::default()
300 },
301 server_info: Some(ServerInfo {
302 name: "rumdl".to_string(),
303 version: Some(env!("CARGO_PKG_VERSION").to_string()),
304 }),
305 })
306 }
307
308 async fn initialized(&self, _: InitializedParams) {
309 let version = env!("CARGO_PKG_VERSION");
310
311 let (binary_path, build_time) = std::env::current_exe().ok().map_or_else(
313 || ("unknown".to_string(), "unknown".to_string()),
314 |path| {
315 let path_str = path.to_str().unwrap_or("unknown").to_string();
316 let build_time = std::fs::metadata(&path)
317 .ok()
318 .and_then(|metadata| metadata.modified().ok())
319 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
320 .and_then(|duration| {
321 let secs = duration.as_secs();
322 chrono::DateTime::from_timestamp(secs as i64, 0)
323 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
324 })
325 .unwrap_or_else(|| "unknown".to_string());
326 (path_str, build_time)
327 },
328 );
329
330 let working_dir = std::env::current_dir()
331 .ok()
332 .and_then(|p| p.to_str().map(std::string::ToString::to_string))
333 .unwrap_or_else(|| "unknown".to_string());
334
335 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
336 log::info!("Working directory: {working_dir}");
337
338 self.client
339 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
340 .await;
341
342 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
344 log::warn!("Failed to trigger initial workspace indexing");
345 } else {
346 log::info!("Triggered initial workspace indexing for cross-file analysis");
347 }
348
349 let markdown_patterns = [
351 "**/*.md",
352 "**/*.markdown",
353 "**/*.mdx",
354 "**/*.mkd",
355 "**/*.mkdn",
356 "**/*.mdown",
357 "**/*.mdwn",
358 "**/*.qmd",
359 "**/*.rmd",
360 ];
361 let config_patterns = [
362 "**/.rumdl.toml",
363 "**/rumdl.toml",
364 "**/pyproject.toml",
365 "**/.markdownlint.json",
366 "**/.markdownlint-cli2.yaml",
367 "**/.markdownlint-cli2.jsonc",
368 ];
369 let watchers: Vec<_> = markdown_patterns
370 .iter()
371 .chain(config_patterns.iter())
372 .map(|pattern| FileSystemWatcher {
373 glob_pattern: GlobPattern::String((*pattern).to_string()),
374 kind: Some(WatchKind::all()),
375 })
376 .collect();
377
378 let registration = Registration {
379 id: "markdown-watcher".to_string(),
380 method: "workspace/didChangeWatchedFiles".to_string(),
381 register_options: Some(
382 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
383 ),
384 };
385
386 if self.client.register_capability(vec![registration]).await.is_err() {
387 log::debug!("Client does not support file watching capability");
388 }
389 }
390
391 async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
392 let uri = params.text_document_position.text_document.uri;
393 let position = params.text_document_position.position;
394
395 let Some(text) = self.get_document_content(&uri).await else {
397 return Ok(None);
398 };
399
400 if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
402 log::debug!(
403 "Code fence completion triggered at {}:{}, current text: '{}'",
404 position.line,
405 position.character,
406 current_text
407 );
408 let items = self
409 .get_language_completions(&uri, ¤t_text, start_col, position)
410 .await;
411 if !items.is_empty() {
412 return Ok(Some(CompletionResponse::Array(items)));
413 }
414 }
415
416 if self.config.read().await.enable_link_completions {
418 let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
422 let skip_link_check = matches!(trigger, Some("." | "-")) && {
423 let line_num = position.line as usize;
424 !text.lines().nth(line_num).is_some_and(|line| line.contains("]("))
427 };
428
429 if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
430 if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
431 log::debug!(
432 "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
433 position.line,
434 position.character,
435 link_info.file_path,
436 partial_anchor
437 );
438 let items = self
439 .get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
440 .await;
441 if !items.is_empty() {
442 return Ok(Some(CompletionResponse::Array(items)));
443 }
444 } else {
445 log::debug!(
446 "File path completion triggered at {}:{}, partial: '{}'",
447 position.line,
448 position.character,
449 link_info.file_path
450 );
451 let list = self
452 .get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
453 .await;
454 if !list.items.is_empty() {
455 return Ok(Some(CompletionResponse::List(list)));
456 }
457 }
458 }
459 }
460
461 Ok(None)
462 }
463
464 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
465 let mut roots = self.workspace_roots.write().await;
467
468 for removed in ¶ms.event.removed {
470 if let Ok(path) = removed.uri.to_file_path() {
471 roots.retain(|r| r != &path);
472 log::info!("Removed workspace root: {}", path.display());
473 }
474 }
475
476 for added in ¶ms.event.added {
478 if let Ok(path) = added.uri.to_file_path()
479 && !roots.contains(&path)
480 {
481 log::info!("Added workspace root: {}", path.display());
482 roots.push(path);
483 }
484 }
485 drop(roots);
486
487 self.config_cache.write().await.clear();
489
490 self.reload_configuration().await;
492
493 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
495 log::warn!("Failed to trigger workspace rescan after folder change");
496 }
497 }
498
499 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
500 log::debug!("Configuration changed: {:?}", params.settings);
501
502 let settings_value = params.settings;
506
507 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
509 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
510 } else {
511 settings_value
512 };
513
514 let has_content_roots_key = matches!(
518 &rumdl_settings,
519 serde_json::Value::Object(obj) if obj.contains_key("linkCompletionContentRoots")
520 );
521
522 let mut config_applied = false;
524 let mut warnings: Vec<String> = Vec::new();
525
526 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
530 && (rule_settings.disable.is_some()
531 || rule_settings.enable.is_some()
532 || rule_settings.line_length.is_some()
533 || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
534 {
535 if let Some(ref disable) = rule_settings.disable {
537 for rule in disable {
538 if !is_valid_rule_name(rule) {
539 warnings.push(format!("Unknown rule in disable list: {rule}"));
540 }
541 }
542 }
543 if let Some(ref enable) = rule_settings.enable {
544 for rule in enable {
545 if !is_valid_rule_name(rule) {
546 warnings.push(format!("Unknown rule in enable list: {rule}"));
547 }
548 }
549 }
550 for rule_name in rule_settings.rules.keys() {
552 if !is_valid_rule_name(rule_name) {
553 warnings.push(format!("Unknown rule in settings: {rule_name}"));
554 }
555 }
556
557 log::info!("Applied rule settings from configuration (Neovim style)");
558 let mut config = self.config.write().await;
559 config.settings = Some(rule_settings);
560 drop(config);
561 config_applied = true;
562 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
563 && (full_config.config_path.is_some()
564 || full_config.enable_rules.is_some()
565 || full_config.disable_rules.is_some()
566 || full_config.settings.is_some()
567 || !full_config.enable_linting
568 || full_config.enable_auto_fix
569 || !full_config.enable_link_completions
570 || !full_config.enable_link_navigation
571 || has_content_roots_key)
572 {
573 if let Some(ref rules) = full_config.enable_rules {
575 for rule in rules {
576 if !is_valid_rule_name(rule) {
577 warnings.push(format!("Unknown rule in enableRules: {rule}"));
578 }
579 }
580 }
581 if let Some(ref rules) = full_config.disable_rules {
582 for rule in rules {
583 if !is_valid_rule_name(rule) {
584 warnings.push(format!("Unknown rule in disableRules: {rule}"));
585 }
586 }
587 }
588
589 log::info!("Applied full LSP configuration from settings");
590 *self.config.write().await = full_config;
591 config_applied = true;
592 } else if let serde_json::Value::Object(obj) = rumdl_settings {
593 let mut config = self.config.write().await;
596
597 let mut rules = std::collections::HashMap::new();
599 let mut disable = Vec::new();
600 let mut enable = Vec::new();
601 let mut line_length = None;
602
603 for (key, value) in obj {
604 match key.as_str() {
605 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
606 Ok(d) => {
607 if d.len() > MAX_RULE_LIST_SIZE {
608 warnings.push(format!(
609 "Too many rules in 'disable' ({} > {}), truncating",
610 d.len(),
611 MAX_RULE_LIST_SIZE
612 ));
613 }
614 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
615 if !is_valid_rule_name(rule) {
616 warnings.push(format!("Unknown rule in disable: {rule}"));
617 }
618 }
619 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
620 }
621 Err(_) => {
622 warnings.push(format!(
623 "Invalid 'disable' value: expected array of strings, got {value}"
624 ));
625 }
626 },
627 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
628 Ok(e) => {
629 if e.len() > MAX_RULE_LIST_SIZE {
630 warnings.push(format!(
631 "Too many rules in 'enable' ({} > {}), truncating",
632 e.len(),
633 MAX_RULE_LIST_SIZE
634 ));
635 }
636 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
637 if !is_valid_rule_name(rule) {
638 warnings.push(format!("Unknown rule in enable: {rule}"));
639 }
640 }
641 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
642 }
643 Err(_) => {
644 warnings.push(format!(
645 "Invalid 'enable' value: expected array of strings, got {value}"
646 ));
647 }
648 },
649 "lineLength" | "line_length" | "line-length" => {
650 if let Some(l) = value.as_u64() {
651 match usize::try_from(l) {
652 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
653 Ok(len) => warnings.push(format!(
654 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
655 )),
656 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
657 }
658 } else {
659 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
660 }
661 }
662 _ if key.starts_with("MD") || key.starts_with("md") => {
664 let normalized = key.to_uppercase();
665 if !is_valid_rule_name(&normalized) {
666 warnings.push(format!("Unknown rule: {key}"));
667 }
668 rules.insert(normalized, value);
669 }
670 _ => {
671 warnings.push(format!("Unknown configuration key: {key}"));
673 }
674 }
675 }
676
677 let settings = LspRuleSettings {
678 line_length,
679 disable: if disable.is_empty() { None } else { Some(disable) },
680 enable: if enable.is_empty() { None } else { Some(enable) },
681 rules,
682 };
683
684 log::info!("Applied Neovim-style rule settings (manual parse)");
685 config.settings = Some(settings);
686 drop(config);
687 config_applied = true;
688 } else {
689 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
690 }
691
692 for warning in &warnings {
694 log::warn!("{warning}");
695 }
696
697 if !warnings.is_empty() {
699 let message = if warnings.len() == 1 {
700 format!("rumdl: {}", warnings[0])
701 } else {
702 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
703 };
704 self.client.log_message(MessageType::WARNING, message).await;
705 }
706
707 if !config_applied {
708 log::debug!("No configuration changes applied");
709 }
710
711 self.config_cache.write().await.clear();
713
714 if config_applied {
722 self.load_configuration(false).await;
723 }
724
725 let doc_list: Vec<_> = {
727 let documents = self.documents.read().await;
728 documents
729 .iter()
730 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
731 .collect()
732 };
733
734 let tasks = doc_list.into_iter().map(|(uri, text)| {
736 let server = self.clone();
737 tokio::spawn(async move {
738 server.update_diagnostics(uri, text, true).await;
739 })
740 });
741
742 let _ = join_all(tasks).await;
744 }
745
746 async fn shutdown(&self) -> JsonRpcResult<()> {
747 log::info!("Shutting down rumdl Language Server");
748
749 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
751
752 Ok(())
753 }
754
755 async fn did_open(&self, params: DidOpenTextDocumentParams) {
756 let uri = params.text_document.uri;
757 let text = params.text_document.text;
758 let version = params.text_document.version;
759
760 let entry = DocumentEntry {
761 content: text.clone(),
762 version: Some(version),
763 from_disk: false,
764 };
765 self.documents.write().await.insert(uri.clone(), entry);
766
767 if let Ok(path) = uri.to_file_path() {
769 let _ = self
770 .update_tx
771 .send(IndexUpdate::FileChanged {
772 path,
773 content: text.clone(),
774 })
775 .await;
776 }
777
778 self.update_diagnostics(uri, text, true).await;
779 }
780
781 async fn did_change(&self, params: DidChangeTextDocumentParams) {
782 let uri = params.text_document.uri;
783 let version = params.text_document.version;
784
785 if let Some(change) = params.content_changes.into_iter().next() {
786 let text = change.text;
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, false).await;
807 }
808 }
809
810 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
811 if params.reason != TextDocumentSaveReason::MANUAL {
814 return Ok(None);
815 }
816
817 let config_guard = self.config.read().await;
818 let enable_auto_fix = config_guard.enable_auto_fix;
819 drop(config_guard);
820
821 if !enable_auto_fix {
822 return Ok(None);
823 }
824
825 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
827 return Ok(None);
828 };
829
830 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
832 Ok(Some(fixed_text)) => {
833 Ok(Some(vec![TextEdit {
835 range: Range {
836 start: Position { line: 0, character: 0 },
837 end: self.get_end_position(&text),
838 },
839 new_text: fixed_text,
840 }]))
841 }
842 Ok(None) => Ok(None),
843 Err(e) => {
844 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
845 Ok(None)
846 }
847 }
848 }
849
850 async fn did_save(&self, params: DidSaveTextDocumentParams) {
851 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
854 self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
855 .await;
856 }
857 }
858
859 async fn did_close(&self, params: DidCloseTextDocumentParams) {
860 self.documents.write().await.remove(¶ms.text_document.uri);
862
863 self.client
866 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
867 .await;
868 }
869
870 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
871 const CONFIG_FILES: &[&str] = &[
873 ".rumdl.toml",
874 "rumdl.toml",
875 "pyproject.toml",
876 ".markdownlint.json",
877 ".markdownlint-cli2.jsonc",
878 ".markdownlint-cli2.yaml",
879 ".markdownlint-cli2.yml",
880 ];
881
882 let mut config_changed = false;
883
884 for change in ¶ms.changes {
885 if let Ok(path) = change.uri.to_file_path() {
886 let file_name = path.file_name().and_then(|f| f.to_str());
887 let extension = path.extension().and_then(|e| e.to_str());
888
889 if let Some(name) = file_name
891 && CONFIG_FILES.contains(&name)
892 && !config_changed
893 {
894 log::info!("Config file changed: {}, invalidating config cache", path.display());
895
896 let mut cache = self.config_cache.write().await;
900 cache.clear();
901
902 drop(cache);
904 self.reload_configuration().await;
905 config_changed = true;
906 }
907
908 if let Some(ext) = extension
910 && is_markdown_extension(ext)
911 {
912 match change.typ {
913 FileChangeType::CREATED | FileChangeType::CHANGED => {
914 let roots = self.workspace_roots.read().await.clone();
919 if crate::lsp::index_worker::path_is_ignored_for_index(&roots, &path) {
920 let _ = self
925 .update_tx
926 .send(IndexUpdate::FileDeleted { path: path.clone() })
927 .await;
928 continue;
929 }
930 if let Ok(content) = tokio::fs::read_to_string(&path).await {
932 let _ = self
933 .update_tx
934 .send(IndexUpdate::FileChanged {
935 path: path.clone(),
936 content,
937 })
938 .await;
939 }
940 }
941 FileChangeType::DELETED => {
942 let _ = self
943 .update_tx
944 .send(IndexUpdate::FileDeleted { path: path.clone() })
945 .await;
946 }
947 _ => {}
948 }
949 }
950 }
951 }
952
953 if config_changed {
955 let docs_to_update: Vec<(Url, String)> = {
956 let docs = self.documents.read().await;
957 docs.iter()
958 .filter(|(_, entry)| !entry.from_disk)
959 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
960 .collect()
961 };
962
963 for (uri, text) in docs_to_update {
964 self.update_diagnostics(uri, text, true).await;
965 }
966 }
967 }
968
969 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
970 let uri = params.text_document.uri;
971 let range = params.range;
972 let requested_kinds = params.context.only;
973
974 if let Some(text) = self.get_document_content(&uri).await {
975 match self.get_code_actions(&uri, &text, range).await {
976 Ok(actions) => {
977 let filtered_actions = if let Some(ref kinds) = requested_kinds
981 && !kinds.is_empty()
982 {
983 actions
984 .into_iter()
985 .filter(|action| {
986 action.kind.as_ref().is_some_and(|action_kind| {
987 let action_kind_str = action_kind.as_str();
988 kinds.iter().any(|requested| {
989 let requested_str = requested.as_str();
990 action_kind_str.starts_with(requested_str)
993 })
994 })
995 })
996 .collect()
997 } else {
998 actions
999 };
1000
1001 let response: Vec<CodeActionOrCommand> = filtered_actions
1002 .into_iter()
1003 .map(CodeActionOrCommand::CodeAction)
1004 .collect();
1005 Ok(Some(response))
1006 }
1007 Err(e) => {
1008 log::error!("Failed to get code actions: {e}");
1009 Ok(None)
1010 }
1011 }
1012 } else {
1013 Ok(None)
1014 }
1015 }
1016
1017 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1018 log::debug!(
1023 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1024 params.range
1025 );
1026
1027 let formatting_params = DocumentFormattingParams {
1028 text_document: params.text_document,
1029 options: params.options,
1030 work_done_progress_params: params.work_done_progress_params,
1031 };
1032
1033 self.formatting(formatting_params).await
1034 }
1035
1036 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1037 let uri = params.text_document.uri;
1038 let options = params.options;
1039
1040 log::debug!("Formatting request for: {uri}");
1041 log::debug!(
1042 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1043 options.insert_final_newline,
1044 options.trim_final_newlines,
1045 options.trim_trailing_whitespace
1046 );
1047
1048 if let Some(text) = self.get_document_content(&uri).await {
1049 let config_guard = self.config.read().await;
1051 let lsp_config = config_guard.clone();
1052 drop(config_guard);
1053
1054 let file_path = uri.to_file_path().ok();
1056 let file_config = if let Some(ref path) = file_path {
1057 self.resolve_config_for_file(path).await
1058 } else {
1059 self.rumdl_config.read().await.clone()
1061 };
1062
1063 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1065
1066 let all_rules = rules::all_rules(&rumdl_config);
1067 let flavor = if let Some(ref path) = file_path {
1068 rumdl_config.get_flavor_for_file(path)
1069 } else {
1070 rumdl_config.markdown_flavor()
1071 };
1072
1073 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1075
1076 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1078
1079 let mut result = text.clone();
1081 match crate::lint(
1082 &text,
1083 &filtered_rules,
1084 false,
1085 flavor,
1086 file_path.clone(),
1087 Some(&rumdl_config),
1088 ) {
1089 Ok(warnings) => {
1090 log::debug!(
1091 "Found {} warnings, {} with fixes",
1092 warnings.len(),
1093 warnings.iter().filter(|w| w.fix.is_some()).count()
1094 );
1095
1096 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1097 if has_fixes {
1098 let fixable_warnings: Vec<_> = warnings
1100 .iter()
1101 .filter(|w| {
1102 if let Some(rule_name) = &w.rule_name {
1103 filtered_rules
1104 .iter()
1105 .find(|r| r.name() == rule_name)
1106 .is_some_and(|r| r.fix_capability() != FixCapability::Unfixable)
1107 } else {
1108 false
1109 }
1110 })
1111 .cloned()
1112 .collect();
1113
1114 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1115 Ok(fixed_content) => {
1116 result = fixed_content;
1117 }
1118 Err(e) => {
1119 log::error!("Failed to apply fixes: {e}");
1120 }
1121 }
1122 }
1123 }
1124 Err(e) => {
1125 log::error!("Failed to lint document: {e}");
1126 }
1127 }
1128
1129 result = Self::apply_formatting_options(result, &options);
1132
1133 if result != text {
1135 log::debug!("Returning formatting edits");
1136 let end_position = self.get_end_position(&text);
1137 let edit = TextEdit {
1138 range: Range {
1139 start: Position { line: 0, character: 0 },
1140 end: end_position,
1141 },
1142 new_text: result,
1143 };
1144 return Ok(Some(vec![edit]));
1145 }
1146
1147 Ok(Some(Vec::new()))
1148 } else {
1149 log::warn!("Document not found: {uri}");
1150 Ok(None)
1151 }
1152 }
1153
1154 async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1155 if !self.config.read().await.enable_link_navigation {
1156 return Ok(None);
1157 }
1158 let uri = params.text_document_position_params.text_document.uri;
1159 let position = params.text_document_position_params.position;
1160
1161 log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1162
1163 Ok(self.handle_goto_definition(&uri, position).await)
1164 }
1165
1166 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1167 if !self.config.read().await.enable_link_navigation {
1168 return Ok(None);
1169 }
1170 let uri = params.text_document_position.text_document.uri;
1171 let position = params.text_document_position.position;
1172
1173 log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1174
1175 Ok(self.handle_references(&uri, position).await)
1176 }
1177
1178 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1179 if !self.config.read().await.enable_link_navigation {
1180 return Ok(None);
1181 }
1182 let uri = params.text_document_position_params.text_document.uri;
1183 let position = params.text_document_position_params.position;
1184
1185 log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1186
1187 Ok(self.handle_hover(&uri, position).await)
1188 }
1189
1190 async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1191 if !self.config.read().await.enable_link_navigation {
1192 return Ok(None);
1193 }
1194 let uri = params.text_document.uri;
1195 let position = params.position;
1196
1197 log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1198
1199 Ok(self.handle_prepare_rename(&uri, position).await)
1200 }
1201
1202 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1203 if !self.config.read().await.enable_link_navigation {
1204 return Ok(None);
1205 }
1206 let uri = params.text_document_position.text_document.uri;
1207 let position = params.text_document_position.position;
1208 let new_name = params.new_name;
1209
1210 log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1211
1212 Ok(self.handle_rename(&uri, position, &new_name).await)
1213 }
1214
1215 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1216 let uri = params.text_document.uri;
1217
1218 if let Some(text) = self.get_open_document_content(&uri).await {
1219 match self.lint_document(&uri, &text, true).await {
1220 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1221 RelatedFullDocumentDiagnosticReport {
1222 related_documents: None,
1223 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1224 result_id: None,
1225 items: diagnostics,
1226 },
1227 },
1228 ))),
1229 Err(e) => {
1230 log::error!("Failed to get diagnostics: {e}");
1231 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1232 RelatedFullDocumentDiagnosticReport {
1233 related_documents: None,
1234 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1235 result_id: None,
1236 items: Vec::new(),
1237 },
1238 },
1239 )))
1240 }
1241 }
1242 } else {
1243 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1244 RelatedFullDocumentDiagnosticReport {
1245 related_documents: None,
1246 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1247 result_id: None,
1248 items: Vec::new(),
1249 },
1250 },
1251 )))
1252 }
1253 }
1254}
1255
1256#[cfg(test)]
1257#[path = "tests.rs"]
1258mod tests;