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