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