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}
93
94impl RumdlLanguageServer {
95 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
96 let mut initial_config = RumdlLspConfig::default();
98 if let Some(path) = cli_config_path {
99 initial_config.config_path = Some(path.to_string());
100 }
101
102 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
104 let index_state = Arc::new(RwLock::new(IndexState::default()));
105 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
106
107 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
109 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
110
111 let worker = IndexWorker::new(
113 update_rx,
114 workspace_index.clone(),
115 index_state.clone(),
116 client.clone(),
117 workspace_roots.clone(),
118 relint_tx,
119 );
120 tokio::spawn(worker.run());
121
122 Self {
123 client,
124 config: Arc::new(RwLock::new(initial_config)),
125 rumdl_config: Arc::new(RwLock::new(Config::default())),
126 documents: Arc::new(RwLock::new(HashMap::new())),
127 workspace_roots,
128 config_cache: Arc::new(RwLock::new(HashMap::new())),
129 workspace_index,
130 index_state,
131 update_tx,
132 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
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 Ok(InitializeResult {
236 capabilities: ServerCapabilities {
237 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
238 open_close: Some(true),
239 change: Some(TextDocumentSyncKind::FULL),
240 will_save: Some(false),
241 will_save_wait_until: Some(true),
242 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
243 include_text: Some(false),
244 })),
245 })),
246 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
247 code_action_kinds: Some(vec![
248 CodeActionKind::QUICKFIX,
249 CodeActionKind::SOURCE_FIX_ALL,
250 CodeActionKind::new("source.fixAll.rumdl"),
251 ]),
252 work_done_progress_options: WorkDoneProgressOptions::default(),
253 resolve_provider: None,
254 })),
255 document_formatting_provider: Some(OneOf::Left(true)),
256 document_range_formatting_provider: Some(OneOf::Left(true)),
257 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
258 identifier: Some("rumdl".to_string()),
259 inter_file_dependencies: true,
260 workspace_diagnostics: false,
261 work_done_progress_options: WorkDoneProgressOptions::default(),
262 })),
263 completion_provider: Some(CompletionOptions {
264 trigger_characters: Some(vec![
265 "`".to_string(),
266 "(".to_string(),
267 "#".to_string(),
268 "/".to_string(),
269 ".".to_string(),
270 "-".to_string(),
271 ]),
272 resolve_provider: Some(false),
273 work_done_progress_options: WorkDoneProgressOptions::default(),
274 all_commit_characters: None,
275 completion_item: None,
276 }),
277 definition_provider: Some(OneOf::Left(true)),
278 references_provider: Some(OneOf::Left(true)),
279 workspace: Some(WorkspaceServerCapabilities {
280 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
281 supported: Some(true),
282 change_notifications: Some(OneOf::Left(true)),
283 }),
284 file_operations: None,
285 }),
286 ..Default::default()
287 },
288 server_info: Some(ServerInfo {
289 name: "rumdl".to_string(),
290 version: Some(env!("CARGO_PKG_VERSION").to_string()),
291 }),
292 })
293 }
294
295 async fn initialized(&self, _: InitializedParams) {
296 let version = env!("CARGO_PKG_VERSION");
297
298 let (binary_path, build_time) = std::env::current_exe()
300 .ok()
301 .map(|path| {
302 let path_str = path.to_str().unwrap_or("unknown").to_string();
303 let build_time = std::fs::metadata(&path)
304 .ok()
305 .and_then(|metadata| metadata.modified().ok())
306 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
307 .and_then(|duration| {
308 let secs = duration.as_secs();
309 chrono::DateTime::from_timestamp(secs as i64, 0)
310 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
311 })
312 .unwrap_or_else(|| "unknown".to_string());
313 (path_str, build_time)
314 })
315 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
316
317 let working_dir = std::env::current_dir()
318 .ok()
319 .and_then(|p| p.to_str().map(|s| s.to_string()))
320 .unwrap_or_else(|| "unknown".to_string());
321
322 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
323 log::info!("Working directory: {working_dir}");
324
325 self.client
326 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
327 .await;
328
329 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
331 log::warn!("Failed to trigger initial workspace indexing");
332 } else {
333 log::info!("Triggered initial workspace indexing for cross-file analysis");
334 }
335
336 let markdown_patterns = [
338 "**/*.md",
339 "**/*.markdown",
340 "**/*.mdx",
341 "**/*.mkd",
342 "**/*.mkdn",
343 "**/*.mdown",
344 "**/*.mdwn",
345 "**/*.qmd",
346 "**/*.rmd",
347 ];
348 let config_patterns = [
349 "**/.rumdl.toml",
350 "**/rumdl.toml",
351 "**/pyproject.toml",
352 "**/.markdownlint.json",
353 ];
354 let watchers: Vec<_> = markdown_patterns
355 .iter()
356 .chain(config_patterns.iter())
357 .map(|pattern| FileSystemWatcher {
358 glob_pattern: GlobPattern::String((*pattern).to_string()),
359 kind: Some(WatchKind::all()),
360 })
361 .collect();
362
363 let registration = Registration {
364 id: "markdown-watcher".to_string(),
365 method: "workspace/didChangeWatchedFiles".to_string(),
366 register_options: Some(
367 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
368 ),
369 };
370
371 if self.client.register_capability(vec![registration]).await.is_err() {
372 log::debug!("Client does not support file watching capability");
373 }
374 }
375
376 async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
377 let uri = params.text_document_position.text_document.uri;
378 let position = params.text_document_position.position;
379
380 let Some(text) = self.get_document_content(&uri).await else {
382 return Ok(None);
383 };
384
385 if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
387 log::debug!(
388 "Code fence completion triggered at {}:{}, current text: '{}'",
389 position.line,
390 position.character,
391 current_text
392 );
393 let items = self
394 .get_language_completions(&uri, ¤t_text, start_col, position)
395 .await;
396 if !items.is_empty() {
397 return Ok(Some(CompletionResponse::Array(items)));
398 }
399 }
400
401 if self.config.read().await.enable_link_completions {
403 let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
407 let skip_link_check = matches!(trigger, Some("." | "-")) && {
408 let line_num = position.line as usize;
409 !text
412 .lines()
413 .nth(line_num)
414 .map(|line| line.contains("]("))
415 .unwrap_or(false)
416 };
417
418 if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
419 let items = if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
420 log::debug!(
421 "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
422 position.line,
423 position.character,
424 link_info.file_path,
425 partial_anchor
426 );
427 self.get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
428 .await
429 } else {
430 log::debug!(
431 "File path completion triggered at {}:{}, partial: '{}'",
432 position.line,
433 position.character,
434 link_info.file_path
435 );
436 self.get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
437 .await
438 };
439 if !items.is_empty() {
440 return Ok(Some(CompletionResponse::Array(items)));
441 }
442 }
443 }
444
445 Ok(None)
446 }
447
448 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
449 let mut roots = self.workspace_roots.write().await;
451
452 for removed in ¶ms.event.removed {
454 if let Ok(path) = removed.uri.to_file_path() {
455 roots.retain(|r| r != &path);
456 log::info!("Removed workspace root: {}", path.display());
457 }
458 }
459
460 for added in ¶ms.event.added {
462 if let Ok(path) = added.uri.to_file_path()
463 && !roots.contains(&path)
464 {
465 log::info!("Added workspace root: {}", path.display());
466 roots.push(path);
467 }
468 }
469 drop(roots);
470
471 self.config_cache.write().await.clear();
473
474 self.reload_configuration().await;
476
477 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
479 log::warn!("Failed to trigger workspace rescan after folder change");
480 }
481 }
482
483 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
484 log::debug!("Configuration changed: {:?}", params.settings);
485
486 let settings_value = params.settings;
490
491 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
493 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
494 } else {
495 settings_value
496 };
497
498 let mut config_applied = false;
500 let mut warnings: Vec<String> = Vec::new();
501
502 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
506 && (rule_settings.disable.is_some()
507 || rule_settings.enable.is_some()
508 || rule_settings.line_length.is_some()
509 || !rule_settings.rules.is_empty())
510 {
511 if let Some(ref disable) = rule_settings.disable {
513 for rule in disable {
514 if !is_valid_rule_name(rule) {
515 warnings.push(format!("Unknown rule in disable list: {rule}"));
516 }
517 }
518 }
519 if let Some(ref enable) = rule_settings.enable {
520 for rule in enable {
521 if !is_valid_rule_name(rule) {
522 warnings.push(format!("Unknown rule in enable list: {rule}"));
523 }
524 }
525 }
526 for rule_name in rule_settings.rules.keys() {
528 if !is_valid_rule_name(rule_name) {
529 warnings.push(format!("Unknown rule in settings: {rule_name}"));
530 }
531 }
532
533 log::info!("Applied rule settings from configuration (Neovim style)");
534 let mut config = self.config.write().await;
535 config.settings = Some(rule_settings);
536 drop(config);
537 config_applied = true;
538 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
539 && (full_config.config_path.is_some()
540 || full_config.enable_rules.is_some()
541 || full_config.disable_rules.is_some()
542 || full_config.settings.is_some()
543 || !full_config.enable_linting
544 || full_config.enable_auto_fix)
545 {
546 if let Some(ref rules) = full_config.enable_rules {
548 for rule in rules {
549 if !is_valid_rule_name(rule) {
550 warnings.push(format!("Unknown rule in enableRules: {rule}"));
551 }
552 }
553 }
554 if let Some(ref rules) = full_config.disable_rules {
555 for rule in rules {
556 if !is_valid_rule_name(rule) {
557 warnings.push(format!("Unknown rule in disableRules: {rule}"));
558 }
559 }
560 }
561
562 log::info!("Applied full LSP configuration from settings");
563 *self.config.write().await = full_config;
564 config_applied = true;
565 } else if let serde_json::Value::Object(obj) = rumdl_settings {
566 let mut config = self.config.write().await;
569
570 let mut rules = std::collections::HashMap::new();
572 let mut disable = Vec::new();
573 let mut enable = Vec::new();
574 let mut line_length = None;
575
576 for (key, value) in obj {
577 match key.as_str() {
578 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
579 Ok(d) => {
580 if d.len() > MAX_RULE_LIST_SIZE {
581 warnings.push(format!(
582 "Too many rules in 'disable' ({} > {}), truncating",
583 d.len(),
584 MAX_RULE_LIST_SIZE
585 ));
586 }
587 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
588 if !is_valid_rule_name(rule) {
589 warnings.push(format!("Unknown rule in disable: {rule}"));
590 }
591 }
592 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
593 }
594 Err(_) => {
595 warnings.push(format!(
596 "Invalid 'disable' value: expected array of strings, got {value}"
597 ));
598 }
599 },
600 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
601 Ok(e) => {
602 if e.len() > MAX_RULE_LIST_SIZE {
603 warnings.push(format!(
604 "Too many rules in 'enable' ({} > {}), truncating",
605 e.len(),
606 MAX_RULE_LIST_SIZE
607 ));
608 }
609 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
610 if !is_valid_rule_name(rule) {
611 warnings.push(format!("Unknown rule in enable: {rule}"));
612 }
613 }
614 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
615 }
616 Err(_) => {
617 warnings.push(format!(
618 "Invalid 'enable' value: expected array of strings, got {value}"
619 ));
620 }
621 },
622 "lineLength" | "line_length" | "line-length" => {
623 if let Some(l) = value.as_u64() {
624 match usize::try_from(l) {
625 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
626 Ok(len) => warnings.push(format!(
627 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
628 )),
629 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
630 }
631 } else {
632 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
633 }
634 }
635 _ if key.starts_with("MD") || key.starts_with("md") => {
637 let normalized = key.to_uppercase();
638 if !is_valid_rule_name(&normalized) {
639 warnings.push(format!("Unknown rule: {key}"));
640 }
641 rules.insert(normalized, value);
642 }
643 _ => {
644 warnings.push(format!("Unknown configuration key: {key}"));
646 }
647 }
648 }
649
650 let settings = LspRuleSettings {
651 line_length,
652 disable: if disable.is_empty() { None } else { Some(disable) },
653 enable: if enable.is_empty() { None } else { Some(enable) },
654 rules,
655 };
656
657 log::info!("Applied Neovim-style rule settings (manual parse)");
658 config.settings = Some(settings);
659 drop(config);
660 config_applied = true;
661 } else {
662 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
663 }
664
665 for warning in &warnings {
667 log::warn!("{warning}");
668 }
669
670 if !warnings.is_empty() {
672 let message = if warnings.len() == 1 {
673 format!("rumdl: {}", warnings[0])
674 } else {
675 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
676 };
677 self.client.log_message(MessageType::WARNING, message).await;
678 }
679
680 if !config_applied {
681 log::debug!("No configuration changes applied");
682 }
683
684 self.config_cache.write().await.clear();
686
687 let doc_list: Vec<_> = {
689 let documents = self.documents.read().await;
690 documents
691 .iter()
692 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
693 .collect()
694 };
695
696 let tasks = doc_list.into_iter().map(|(uri, text)| {
698 let server = self.clone();
699 tokio::spawn(async move {
700 server.update_diagnostics(uri, text, true).await;
701 })
702 });
703
704 let _ = join_all(tasks).await;
706 }
707
708 async fn shutdown(&self) -> JsonRpcResult<()> {
709 log::info!("Shutting down rumdl Language Server");
710
711 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
713
714 Ok(())
715 }
716
717 async fn did_open(&self, params: DidOpenTextDocumentParams) {
718 let uri = params.text_document.uri;
719 let text = params.text_document.text;
720 let version = params.text_document.version;
721
722 let entry = DocumentEntry {
723 content: text.clone(),
724 version: Some(version),
725 from_disk: false,
726 };
727 self.documents.write().await.insert(uri.clone(), entry);
728
729 if let Ok(path) = uri.to_file_path() {
731 let _ = self
732 .update_tx
733 .send(IndexUpdate::FileChanged {
734 path,
735 content: text.clone(),
736 })
737 .await;
738 }
739
740 self.update_diagnostics(uri, text, true).await;
741 }
742
743 async fn did_change(&self, params: DidChangeTextDocumentParams) {
744 let uri = params.text_document.uri;
745 let version = params.text_document.version;
746
747 if let Some(change) = params.content_changes.into_iter().next() {
748 let text = change.text;
749
750 let entry = DocumentEntry {
751 content: text.clone(),
752 version: Some(version),
753 from_disk: false,
754 };
755 self.documents.write().await.insert(uri.clone(), entry);
756
757 if let Ok(path) = uri.to_file_path() {
759 let _ = self
760 .update_tx
761 .send(IndexUpdate::FileChanged {
762 path,
763 content: text.clone(),
764 })
765 .await;
766 }
767
768 self.update_diagnostics(uri, text, false).await;
769 }
770 }
771
772 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
773 if params.reason != TextDocumentSaveReason::MANUAL {
776 return Ok(None);
777 }
778
779 let config_guard = self.config.read().await;
780 let enable_auto_fix = config_guard.enable_auto_fix;
781 drop(config_guard);
782
783 if !enable_auto_fix {
784 return Ok(None);
785 }
786
787 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
789 return Ok(None);
790 };
791
792 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
794 Ok(Some(fixed_text)) => {
795 Ok(Some(vec![TextEdit {
797 range: Range {
798 start: Position { line: 0, character: 0 },
799 end: self.get_end_position(&text),
800 },
801 new_text: fixed_text,
802 }]))
803 }
804 Ok(None) => Ok(None),
805 Err(e) => {
806 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
807 Ok(None)
808 }
809 }
810 }
811
812 async fn did_save(&self, params: DidSaveTextDocumentParams) {
813 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
816 self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
817 .await;
818 }
819 }
820
821 async fn did_close(&self, params: DidCloseTextDocumentParams) {
822 self.documents.write().await.remove(¶ms.text_document.uri);
824
825 self.client
828 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
829 .await;
830 }
831
832 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
833 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
835
836 let mut config_changed = false;
837
838 for change in ¶ms.changes {
839 if let Ok(path) = change.uri.to_file_path() {
840 let file_name = path.file_name().and_then(|f| f.to_str());
841 let extension = path.extension().and_then(|e| e.to_str());
842
843 if let Some(name) = file_name
845 && CONFIG_FILES.contains(&name)
846 && !config_changed
847 {
848 log::info!("Config file changed: {}, invalidating config cache", path.display());
849
850 let mut cache = self.config_cache.write().await;
854 cache.clear();
855
856 drop(cache);
858 self.reload_configuration().await;
859 config_changed = true;
860 }
861
862 if let Some(ext) = extension
864 && is_markdown_extension(ext)
865 {
866 match change.typ {
867 FileChangeType::CREATED | FileChangeType::CHANGED => {
868 if let Ok(content) = tokio::fs::read_to_string(&path).await {
870 let _ = self
871 .update_tx
872 .send(IndexUpdate::FileChanged {
873 path: path.clone(),
874 content,
875 })
876 .await;
877 }
878 }
879 FileChangeType::DELETED => {
880 let _ = self
881 .update_tx
882 .send(IndexUpdate::FileDeleted { path: path.clone() })
883 .await;
884 }
885 _ => {}
886 }
887 }
888 }
889 }
890
891 if config_changed {
893 let docs_to_update: Vec<(Url, String)> = {
894 let docs = self.documents.read().await;
895 docs.iter()
896 .filter(|(_, entry)| !entry.from_disk)
897 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
898 .collect()
899 };
900
901 for (uri, text) in docs_to_update {
902 self.update_diagnostics(uri, text, true).await;
903 }
904 }
905 }
906
907 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
908 let uri = params.text_document.uri;
909 let range = params.range;
910 let requested_kinds = params.context.only;
911
912 if let Some(text) = self.get_document_content(&uri).await {
913 match self.get_code_actions(&uri, &text, range).await {
914 Ok(actions) => {
915 let filtered_actions = if let Some(ref kinds) = requested_kinds
919 && !kinds.is_empty()
920 {
921 actions
922 .into_iter()
923 .filter(|action| {
924 action.kind.as_ref().is_some_and(|action_kind| {
925 let action_kind_str = action_kind.as_str();
926 kinds.iter().any(|requested| {
927 let requested_str = requested.as_str();
928 action_kind_str.starts_with(requested_str)
931 })
932 })
933 })
934 .collect()
935 } else {
936 actions
937 };
938
939 let response: Vec<CodeActionOrCommand> = filtered_actions
940 .into_iter()
941 .map(CodeActionOrCommand::CodeAction)
942 .collect();
943 Ok(Some(response))
944 }
945 Err(e) => {
946 log::error!("Failed to get code actions: {e}");
947 Ok(None)
948 }
949 }
950 } else {
951 Ok(None)
952 }
953 }
954
955 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
956 log::debug!(
961 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
962 params.range
963 );
964
965 let formatting_params = DocumentFormattingParams {
966 text_document: params.text_document,
967 options: params.options,
968 work_done_progress_params: params.work_done_progress_params,
969 };
970
971 self.formatting(formatting_params).await
972 }
973
974 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
975 let uri = params.text_document.uri;
976 let options = params.options;
977
978 log::debug!("Formatting request for: {uri}");
979 log::debug!(
980 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
981 options.insert_final_newline,
982 options.trim_final_newlines,
983 options.trim_trailing_whitespace
984 );
985
986 if let Some(text) = self.get_document_content(&uri).await {
987 let config_guard = self.config.read().await;
989 let lsp_config = config_guard.clone();
990 drop(config_guard);
991
992 let file_path = uri.to_file_path().ok();
994 let file_config = if let Some(ref path) = file_path {
995 self.resolve_config_for_file(path).await
996 } else {
997 self.rumdl_config.read().await.clone()
999 };
1000
1001 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
1003
1004 let all_rules = rules::all_rules(&rumdl_config);
1005 let flavor = if let Some(ref path) = file_path {
1006 rumdl_config.get_flavor_for_file(path)
1007 } else {
1008 rumdl_config.markdown_flavor()
1009 };
1010
1011 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
1013
1014 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
1016
1017 let mut result = text.clone();
1019 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
1020 Ok(warnings) => {
1021 log::debug!(
1022 "Found {} warnings, {} with fixes",
1023 warnings.len(),
1024 warnings.iter().filter(|w| w.fix.is_some()).count()
1025 );
1026
1027 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
1028 if has_fixes {
1029 let fixable_warnings: Vec<_> = warnings
1031 .iter()
1032 .filter(|w| {
1033 if let Some(rule_name) = &w.rule_name {
1034 filtered_rules
1035 .iter()
1036 .find(|r| r.name() == rule_name)
1037 .map(|r| r.fix_capability() != FixCapability::Unfixable)
1038 .unwrap_or(false)
1039 } else {
1040 false
1041 }
1042 })
1043 .cloned()
1044 .collect();
1045
1046 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
1047 Ok(fixed_content) => {
1048 result = fixed_content;
1049 }
1050 Err(e) => {
1051 log::error!("Failed to apply fixes: {e}");
1052 }
1053 }
1054 }
1055 }
1056 Err(e) => {
1057 log::error!("Failed to lint document: {e}");
1058 }
1059 }
1060
1061 result = Self::apply_formatting_options(result, &options);
1064
1065 if result != text {
1067 log::debug!("Returning formatting edits");
1068 let end_position = self.get_end_position(&text);
1069 let edit = TextEdit {
1070 range: Range {
1071 start: Position { line: 0, character: 0 },
1072 end: end_position,
1073 },
1074 new_text: result,
1075 };
1076 return Ok(Some(vec![edit]));
1077 }
1078
1079 Ok(Some(Vec::new()))
1080 } else {
1081 log::warn!("Document not found: {uri}");
1082 Ok(None)
1083 }
1084 }
1085
1086 async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1087 let uri = params.text_document_position_params.text_document.uri;
1088 let position = params.text_document_position_params.position;
1089
1090 log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1091
1092 Ok(self.handle_goto_definition(&uri, position).await)
1093 }
1094
1095 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1096 let uri = params.text_document_position.text_document.uri;
1097 let position = params.text_document_position.position;
1098
1099 log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1100
1101 Ok(self.handle_references(&uri, position).await)
1102 }
1103
1104 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1105 let uri = params.text_document.uri;
1106
1107 if let Some(text) = self.get_open_document_content(&uri).await {
1108 match self.lint_document(&uri, &text, true).await {
1109 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1110 RelatedFullDocumentDiagnosticReport {
1111 related_documents: None,
1112 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1113 result_id: None,
1114 items: diagnostics,
1115 },
1116 },
1117 ))),
1118 Err(e) => {
1119 log::error!("Failed to get diagnostics: {e}");
1120 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1121 RelatedFullDocumentDiagnosticReport {
1122 related_documents: None,
1123 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1124 result_id: None,
1125 items: Vec::new(),
1126 },
1127 },
1128 )))
1129 }
1130 }
1131 } else {
1132 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1133 RelatedFullDocumentDiagnosticReport {
1134 related_documents: None,
1135 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1136 result_id: None,
1137 items: Vec::new(),
1138 },
1139 },
1140 )))
1141 }
1142 }
1143}
1144
1145#[cfg(test)]
1146#[path = "tests.rs"]
1147mod tests;