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 let items = 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 self.get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
439 .await
440 } else {
441 log::debug!(
442 "File path completion triggered at {}:{}, partial: '{}'",
443 position.line,
444 position.character,
445 link_info.file_path
446 );
447 self.get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
448 .await
449 };
450 if !items.is_empty() {
451 return Ok(Some(CompletionResponse::Array(items)));
452 }
453 }
454 }
455
456 Ok(None)
457 }
458
459 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
460 let mut roots = self.workspace_roots.write().await;
462
463 for removed in ¶ms.event.removed {
465 if let Ok(path) = removed.uri.to_file_path() {
466 roots.retain(|r| r != &path);
467 log::info!("Removed workspace root: {}", path.display());
468 }
469 }
470
471 for added in ¶ms.event.added {
473 if let Ok(path) = added.uri.to_file_path()
474 && !roots.contains(&path)
475 {
476 log::info!("Added workspace root: {}", path.display());
477 roots.push(path);
478 }
479 }
480 drop(roots);
481
482 self.config_cache.write().await.clear();
484
485 self.reload_configuration().await;
487
488 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
490 log::warn!("Failed to trigger workspace rescan after folder change");
491 }
492 }
493
494 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
495 log::debug!("Configuration changed: {:?}", params.settings);
496
497 let settings_value = params.settings;
501
502 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
504 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
505 } else {
506 settings_value
507 };
508
509 let mut config_applied = false;
511 let mut warnings: Vec<String> = Vec::new();
512
513 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
517 && (rule_settings.disable.is_some()
518 || rule_settings.enable.is_some()
519 || rule_settings.line_length.is_some()
520 || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
521 {
522 if let Some(ref disable) = rule_settings.disable {
524 for rule in disable {
525 if !is_valid_rule_name(rule) {
526 warnings.push(format!("Unknown rule in disable list: {rule}"));
527 }
528 }
529 }
530 if let Some(ref enable) = rule_settings.enable {
531 for rule in enable {
532 if !is_valid_rule_name(rule) {
533 warnings.push(format!("Unknown rule in enable list: {rule}"));
534 }
535 }
536 }
537 for rule_name in rule_settings.rules.keys() {
539 if !is_valid_rule_name(rule_name) {
540 warnings.push(format!("Unknown rule in settings: {rule_name}"));
541 }
542 }
543
544 log::info!("Applied rule settings from configuration (Neovim style)");
545 let mut config = self.config.write().await;
546 config.settings = Some(rule_settings);
547 drop(config);
548 config_applied = true;
549 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
550 && (full_config.config_path.is_some()
551 || full_config.enable_rules.is_some()
552 || full_config.disable_rules.is_some()
553 || full_config.settings.is_some()
554 || !full_config.enable_linting
555 || full_config.enable_auto_fix
556 || !full_config.enable_link_completions
557 || !full_config.enable_link_navigation)
558 {
559 if let Some(ref rules) = full_config.enable_rules {
561 for rule in rules {
562 if !is_valid_rule_name(rule) {
563 warnings.push(format!("Unknown rule in enableRules: {rule}"));
564 }
565 }
566 }
567 if let Some(ref rules) = full_config.disable_rules {
568 for rule in rules {
569 if !is_valid_rule_name(rule) {
570 warnings.push(format!("Unknown rule in disableRules: {rule}"));
571 }
572 }
573 }
574
575 log::info!("Applied full LSP configuration from settings");
576 *self.config.write().await = full_config;
577 config_applied = true;
578 } else if let serde_json::Value::Object(obj) = rumdl_settings {
579 let mut config = self.config.write().await;
582
583 let mut rules = std::collections::HashMap::new();
585 let mut disable = Vec::new();
586 let mut enable = Vec::new();
587 let mut line_length = None;
588
589 for (key, value) in obj {
590 match key.as_str() {
591 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
592 Ok(d) => {
593 if d.len() > MAX_RULE_LIST_SIZE {
594 warnings.push(format!(
595 "Too many rules in 'disable' ({} > {}), truncating",
596 d.len(),
597 MAX_RULE_LIST_SIZE
598 ));
599 }
600 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
601 if !is_valid_rule_name(rule) {
602 warnings.push(format!("Unknown rule in disable: {rule}"));
603 }
604 }
605 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
606 }
607 Err(_) => {
608 warnings.push(format!(
609 "Invalid 'disable' value: expected array of strings, got {value}"
610 ));
611 }
612 },
613 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
614 Ok(e) => {
615 if e.len() > MAX_RULE_LIST_SIZE {
616 warnings.push(format!(
617 "Too many rules in 'enable' ({} > {}), truncating",
618 e.len(),
619 MAX_RULE_LIST_SIZE
620 ));
621 }
622 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
623 if !is_valid_rule_name(rule) {
624 warnings.push(format!("Unknown rule in enable: {rule}"));
625 }
626 }
627 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
628 }
629 Err(_) => {
630 warnings.push(format!(
631 "Invalid 'enable' value: expected array of strings, got {value}"
632 ));
633 }
634 },
635 "lineLength" | "line_length" | "line-length" => {
636 if let Some(l) = value.as_u64() {
637 match usize::try_from(l) {
638 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
639 Ok(len) => warnings.push(format!(
640 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
641 )),
642 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
643 }
644 } else {
645 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
646 }
647 }
648 _ if key.starts_with("MD") || key.starts_with("md") => {
650 let normalized = key.to_uppercase();
651 if !is_valid_rule_name(&normalized) {
652 warnings.push(format!("Unknown rule: {key}"));
653 }
654 rules.insert(normalized, value);
655 }
656 _ => {
657 warnings.push(format!("Unknown configuration key: {key}"));
659 }
660 }
661 }
662
663 let settings = LspRuleSettings {
664 line_length,
665 disable: if disable.is_empty() { None } else { Some(disable) },
666 enable: if enable.is_empty() { None } else { Some(enable) },
667 rules,
668 };
669
670 log::info!("Applied Neovim-style rule settings (manual parse)");
671 config.settings = Some(settings);
672 drop(config);
673 config_applied = true;
674 } else {
675 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
676 }
677
678 for warning in &warnings {
680 log::warn!("{warning}");
681 }
682
683 if !warnings.is_empty() {
685 let message = if warnings.len() == 1 {
686 format!("rumdl: {}", warnings[0])
687 } else {
688 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
689 };
690 self.client.log_message(MessageType::WARNING, message).await;
691 }
692
693 if !config_applied {
694 log::debug!("No configuration changes applied");
695 }
696
697 self.config_cache.write().await.clear();
699
700 if config_applied {
708 self.load_configuration(false).await;
709 }
710
711 let doc_list: Vec<_> = {
713 let documents = self.documents.read().await;
714 documents
715 .iter()
716 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
717 .collect()
718 };
719
720 let tasks = doc_list.into_iter().map(|(uri, text)| {
722 let server = self.clone();
723 tokio::spawn(async move {
724 server.update_diagnostics(uri, text, true).await;
725 })
726 });
727
728 let _ = join_all(tasks).await;
730 }
731
732 async fn shutdown(&self) -> JsonRpcResult<()> {
733 log::info!("Shutting down rumdl Language Server");
734
735 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
737
738 Ok(())
739 }
740
741 async fn did_open(&self, params: DidOpenTextDocumentParams) {
742 let uri = params.text_document.uri;
743 let text = params.text_document.text;
744 let version = params.text_document.version;
745
746 let entry = DocumentEntry {
747 content: text.clone(),
748 version: Some(version),
749 from_disk: false,
750 };
751 self.documents.write().await.insert(uri.clone(), entry);
752
753 if let Ok(path) = uri.to_file_path() {
755 let _ = self
756 .update_tx
757 .send(IndexUpdate::FileChanged {
758 path,
759 content: text.clone(),
760 })
761 .await;
762 }
763
764 self.update_diagnostics(uri, text, true).await;
765 }
766
767 async fn did_change(&self, params: DidChangeTextDocumentParams) {
768 let uri = params.text_document.uri;
769 let version = params.text_document.version;
770
771 if let Some(change) = params.content_changes.into_iter().next() {
772 let text = change.text;
773
774 let entry = DocumentEntry {
775 content: text.clone(),
776 version: Some(version),
777 from_disk: false,
778 };
779 self.documents.write().await.insert(uri.clone(), entry);
780
781 if let Ok(path) = uri.to_file_path() {
783 let _ = self
784 .update_tx
785 .send(IndexUpdate::FileChanged {
786 path,
787 content: text.clone(),
788 })
789 .await;
790 }
791
792 self.update_diagnostics(uri, text, false).await;
793 }
794 }
795
796 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
797 if params.reason != TextDocumentSaveReason::MANUAL {
800 return Ok(None);
801 }
802
803 let config_guard = self.config.read().await;
804 let enable_auto_fix = config_guard.enable_auto_fix;
805 drop(config_guard);
806
807 if !enable_auto_fix {
808 return Ok(None);
809 }
810
811 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
813 return Ok(None);
814 };
815
816 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
818 Ok(Some(fixed_text)) => {
819 Ok(Some(vec![TextEdit {
821 range: Range {
822 start: Position { line: 0, character: 0 },
823 end: self.get_end_position(&text),
824 },
825 new_text: fixed_text,
826 }]))
827 }
828 Ok(None) => Ok(None),
829 Err(e) => {
830 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
831 Ok(None)
832 }
833 }
834 }
835
836 async fn did_save(&self, params: DidSaveTextDocumentParams) {
837 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
840 self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
841 .await;
842 }
843 }
844
845 async fn did_close(&self, params: DidCloseTextDocumentParams) {
846 self.documents.write().await.remove(¶ms.text_document.uri);
848
849 self.client
852 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
853 .await;
854 }
855
856 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
857 const CONFIG_FILES: &[&str] = &[
859 ".rumdl.toml",
860 "rumdl.toml",
861 "pyproject.toml",
862 ".markdownlint.json",
863 ".markdownlint-cli2.jsonc",
864 ".markdownlint-cli2.yaml",
865 ".markdownlint-cli2.yml",
866 ];
867
868 let mut config_changed = false;
869
870 for change in ¶ms.changes {
871 if let Ok(path) = change.uri.to_file_path() {
872 let file_name = path.file_name().and_then(|f| f.to_str());
873 let extension = path.extension().and_then(|e| e.to_str());
874
875 if let Some(name) = file_name
877 && CONFIG_FILES.contains(&name)
878 && !config_changed
879 {
880 log::info!("Config file changed: {}, invalidating config cache", path.display());
881
882 let mut cache = self.config_cache.write().await;
886 cache.clear();
887
888 drop(cache);
890 self.reload_configuration().await;
891 config_changed = true;
892 }
893
894 if let Some(ext) = extension
896 && is_markdown_extension(ext)
897 {
898 match change.typ {
899 FileChangeType::CREATED | FileChangeType::CHANGED => {
900 if let Ok(content) = tokio::fs::read_to_string(&path).await {
902 let _ = self
903 .update_tx
904 .send(IndexUpdate::FileChanged {
905 path: path.clone(),
906 content,
907 })
908 .await;
909 }
910 }
911 FileChangeType::DELETED => {
912 let _ = self
913 .update_tx
914 .send(IndexUpdate::FileDeleted { path: path.clone() })
915 .await;
916 }
917 _ => {}
918 }
919 }
920 }
921 }
922
923 if config_changed {
925 let docs_to_update: Vec<(Url, String)> = {
926 let docs = self.documents.read().await;
927 docs.iter()
928 .filter(|(_, entry)| !entry.from_disk)
929 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
930 .collect()
931 };
932
933 for (uri, text) in docs_to_update {
934 self.update_diagnostics(uri, text, true).await;
935 }
936 }
937 }
938
939 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
940 let uri = params.text_document.uri;
941 let range = params.range;
942 let requested_kinds = params.context.only;
943
944 if let Some(text) = self.get_document_content(&uri).await {
945 match self.get_code_actions(&uri, &text, range).await {
946 Ok(actions) => {
947 let filtered_actions = if let Some(ref kinds) = requested_kinds
951 && !kinds.is_empty()
952 {
953 actions
954 .into_iter()
955 .filter(|action| {
956 action.kind.as_ref().is_some_and(|action_kind| {
957 let action_kind_str = action_kind.as_str();
958 kinds.iter().any(|requested| {
959 let requested_str = requested.as_str();
960 action_kind_str.starts_with(requested_str)
963 })
964 })
965 })
966 .collect()
967 } else {
968 actions
969 };
970
971 let response: Vec<CodeActionOrCommand> = filtered_actions
972 .into_iter()
973 .map(CodeActionOrCommand::CodeAction)
974 .collect();
975 Ok(Some(response))
976 }
977 Err(e) => {
978 log::error!("Failed to get code actions: {e}");
979 Ok(None)
980 }
981 }
982 } else {
983 Ok(None)
984 }
985 }
986
987 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
988 log::debug!(
993 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
994 params.range
995 );
996
997 let formatting_params = DocumentFormattingParams {
998 text_document: params.text_document,
999 options: params.options,
1000 work_done_progress_params: params.work_done_progress_params,
1001 };
1002
1003 self.formatting(formatting_params).await
1004 }
1005
1006 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1007 let uri = params.text_document.uri;
1008 let options = params.options;
1009
1010 log::debug!("Formatting request for: {uri}");
1011 log::debug!(
1012 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1013 options.insert_final_newline,
1014 options.trim_final_newlines,
1015 options.trim_trailing_whitespace
1016 );
1017
1018 if let Some(text) = self.get_document_content(&uri).await {
1019 let config_guard = self.config.read().await;
1021 let lsp_config = config_guard.clone();
1022 drop(config_guard);
1023
1024 let file_path = uri.to_file_path().ok();
1026 let file_config = if let Some(ref path) = file_path {
1027 self.resolve_config_for_file(path).await
1028 } else {
1029 self.rumdl_config.read().await.clone()
1031 };
1032
1033 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1035
1036 let all_rules = rules::all_rules(&rumdl_config);
1037 let flavor = if let Some(ref path) = file_path {
1038 rumdl_config.get_flavor_for_file(path)
1039 } else {
1040 rumdl_config.markdown_flavor()
1041 };
1042
1043 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1045
1046 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1048
1049 let mut result = text.clone();
1051 match crate::lint(
1052 &text,
1053 &filtered_rules,
1054 false,
1055 flavor,
1056 file_path.clone(),
1057 Some(&rumdl_config),
1058 ) {
1059 Ok(warnings) => {
1060 log::debug!(
1061 "Found {} warnings, {} with fixes",
1062 warnings.len(),
1063 warnings.iter().filter(|w| w.fix.is_some()).count()
1064 );
1065
1066 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1067 if has_fixes {
1068 let fixable_warnings: Vec<_> = warnings
1070 .iter()
1071 .filter(|w| {
1072 if let Some(rule_name) = &w.rule_name {
1073 filtered_rules
1074 .iter()
1075 .find(|r| r.name() == rule_name)
1076 .is_some_and(|r| r.fix_capability() != FixCapability::Unfixable)
1077 } else {
1078 false
1079 }
1080 })
1081 .cloned()
1082 .collect();
1083
1084 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1085 Ok(fixed_content) => {
1086 result = fixed_content;
1087 }
1088 Err(e) => {
1089 log::error!("Failed to apply fixes: {e}");
1090 }
1091 }
1092 }
1093 }
1094 Err(e) => {
1095 log::error!("Failed to lint document: {e}");
1096 }
1097 }
1098
1099 result = Self::apply_formatting_options(result, &options);
1102
1103 if result != text {
1105 log::debug!("Returning formatting edits");
1106 let end_position = self.get_end_position(&text);
1107 let edit = TextEdit {
1108 range: Range {
1109 start: Position { line: 0, character: 0 },
1110 end: end_position,
1111 },
1112 new_text: result,
1113 };
1114 return Ok(Some(vec![edit]));
1115 }
1116
1117 Ok(Some(Vec::new()))
1118 } else {
1119 log::warn!("Document not found: {uri}");
1120 Ok(None)
1121 }
1122 }
1123
1124 async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1125 if !self.config.read().await.enable_link_navigation {
1126 return Ok(None);
1127 }
1128 let uri = params.text_document_position_params.text_document.uri;
1129 let position = params.text_document_position_params.position;
1130
1131 log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1132
1133 Ok(self.handle_goto_definition(&uri, position).await)
1134 }
1135
1136 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1137 if !self.config.read().await.enable_link_navigation {
1138 return Ok(None);
1139 }
1140 let uri = params.text_document_position.text_document.uri;
1141 let position = params.text_document_position.position;
1142
1143 log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1144
1145 Ok(self.handle_references(&uri, position).await)
1146 }
1147
1148 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1149 if !self.config.read().await.enable_link_navigation {
1150 return Ok(None);
1151 }
1152 let uri = params.text_document_position_params.text_document.uri;
1153 let position = params.text_document_position_params.position;
1154
1155 log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1156
1157 Ok(self.handle_hover(&uri, position).await)
1158 }
1159
1160 async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1161 if !self.config.read().await.enable_link_navigation {
1162 return Ok(None);
1163 }
1164 let uri = params.text_document.uri;
1165 let position = params.position;
1166
1167 log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1168
1169 Ok(self.handle_prepare_rename(&uri, position).await)
1170 }
1171
1172 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1173 if !self.config.read().await.enable_link_navigation {
1174 return Ok(None);
1175 }
1176 let uri = params.text_document_position.text_document.uri;
1177 let position = params.text_document_position.position;
1178 let new_name = params.new_name;
1179
1180 log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1181
1182 Ok(self.handle_rename(&uri, position, &new_name).await)
1183 }
1184
1185 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1186 let uri = params.text_document.uri;
1187
1188 if let Some(text) = self.get_open_document_content(&uri).await {
1189 match self.lint_document(&uri, &text, true).await {
1190 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1191 RelatedFullDocumentDiagnosticReport {
1192 related_documents: None,
1193 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1194 result_id: None,
1195 items: diagnostics,
1196 },
1197 },
1198 ))),
1199 Err(e) => {
1200 log::error!("Failed to get diagnostics: {e}");
1201 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1202 RelatedFullDocumentDiagnosticReport {
1203 related_documents: None,
1204 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1205 result_id: None,
1206 items: Vec::new(),
1207 },
1208 },
1209 )))
1210 }
1211 }
1212 } else {
1213 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1214 RelatedFullDocumentDiagnosticReport {
1215 related_documents: None,
1216 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1217 result_id: None,
1218 items: Vec::new(),
1219 },
1220 },
1221 )))
1222 }
1223 }
1224}
1225
1226#[cfg(test)]
1227#[path = "tests.rs"]
1228mod tests;