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