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, ConfigValidated, SourcedConfig, is_valid_rule_name};
17use crate::discovery::{ExcludeMatchers, is_markdown_extension};
18use crate::lsp::index_worker::IndexWorker;
19use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig};
20use crate::workspace_index::WorkspaceIndex;
21
22const MAX_RULE_LIST_SIZE: usize = 100;
24
25const MAX_LINE_LENGTH: usize = 10_000;
27
28fn merge_lsp_config(current: &RumdlLspConfig, incoming: &serde_json::Value) -> Option<RumdlLspConfig> {
40 let serde_json::Value::Object(incoming) = incoming else {
41 return None;
42 };
43 let serde_json::Value::Object(mut base) = serde_json::to_value(current).ok()? else {
44 return None;
45 };
46 for (key, value) in incoming {
47 base.insert(key.clone(), value.clone());
48 }
49 serde_json::from_value(serde_json::Value::Object(base)).ok()
50}
51
52#[derive(Clone, Debug, PartialEq)]
54pub(crate) struct DocumentEntry {
55 pub(crate) content: String,
57 pub(crate) version: Option<i32>,
59 pub(crate) from_disk: bool,
61}
62
63#[derive(Clone, Debug)]
65pub(crate) struct ConfigCacheEntry {
66 pub(crate) config: Config,
68 pub(crate) sourced: Option<Arc<SourcedConfig<ConfigValidated>>>,
73 pub(crate) config_file: Option<PathBuf>,
75 pub(crate) from_global_fallback: bool,
77}
78
79#[derive(Clone)]
89pub struct RumdlLanguageServer {
90 pub(crate) client: Client,
91 pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
93 pub(crate) rumdl_config: Arc<RwLock<Config>>,
95 pub(crate) rumdl_sourced: Arc<RwLock<Option<Arc<SourcedConfig<ConfigValidated>>>>>,
98 pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
100 pub(crate) document_aliases: Arc<RwLock<HashMap<Url, Vec<Url>>>>,
113 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
115 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
118 pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
120 pub(crate) index_state: Arc<RwLock<IndexState>>,
122 pub(crate) update_tx: mpsc::Sender<IndexUpdate>,
124 pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
127 pub(crate) client_supports_hierarchical_symbols: Arc<RwLock<bool>>,
131 pub(crate) cli_config_path: Option<String>,
139}
140
141impl RumdlLanguageServer {
142 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
143 let initial_config = RumdlLspConfig::default();
144 let cli_config_path = cli_config_path.map(str::to_string);
145
146 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
148 let index_state = Arc::new(RwLock::new(IndexState::default()));
149 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
150 let rumdl_config = Arc::new(RwLock::new(Config::default()));
151
152 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
154 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
155
156 let worker = IndexWorker::new(
158 update_rx,
159 workspace_index.clone(),
160 index_state.clone(),
161 client.clone(),
162 workspace_roots.clone(),
163 relint_tx,
164 rumdl_config.clone(),
165 );
166 tokio::spawn(worker.run());
167
168 Self {
169 client,
170 config: Arc::new(RwLock::new(initial_config)),
171 rumdl_config,
172 rumdl_sourced: Arc::new(RwLock::new(None)),
173 documents: Arc::new(RwLock::new(HashMap::new())),
174 document_aliases: Arc::new(RwLock::new(HashMap::new())),
175 workspace_roots,
176 config_cache: Arc::new(RwLock::new(HashMap::new())),
177 workspace_index,
178 index_state,
179 update_tx,
180 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
181 client_supports_hierarchical_symbols: Arc::new(RwLock::new(false)),
182 cli_config_path,
183 }
184 }
185
186 pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
192 let uri = &self.store_uri(uri).await;
193
194 {
196 let docs = self.documents.read().await;
197 if let Some(entry) = docs.get(uri) {
198 return Some(entry.content.clone());
199 }
200 }
201
202 if let Ok(path) = uri.to_file_path() {
204 if let Ok(content) = tokio::fs::read_to_string(&path).await {
205 let entry = DocumentEntry {
207 content: content.clone(),
208 version: None,
209 from_disk: true,
210 };
211
212 let mut docs = self.documents.write().await;
213 docs.insert(uri.clone(), entry);
214
215 log::debug!("Loaded document from disk and cached: {uri}");
216 return Some(content);
217 } else {
218 log::debug!("Failed to read file from disk: {uri}");
219 }
220 }
221
222 None
223 }
224
225 async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
231 let uri = self.store_uri(uri).await;
232 let docs = self.documents.read().await;
233 docs.get(&uri)
234 .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
235 }
236
237 async fn store_uri(&self, uri: &Url) -> Url {
249 let Some(spellings) = self.document_aliases.read().await.get(uri).cloned() else {
250 return uri.clone();
251 };
252 let docs = self.documents.read().await;
253 let is_open = |u: &Url| matches!(docs.get(u), Some(entry) if !entry.from_disk);
254 if is_open(uri) {
255 return uri.clone();
256 }
257 spellings
259 .iter()
260 .rev()
261 .find(|u| is_open(u))
262 .cloned()
263 .unwrap_or_else(|| uri.clone())
264 }
265
266 pub(super) async fn resolve_flavor_for_uri(&self, uri: &Url) -> crate::config::MarkdownFlavor {
269 match super::resolve_uri(uri) {
270 Some(path) => self.resolve_config_for_file(&path).await.get_flavor_for_file(&path),
271 None => self.rumdl_config.read().await.markdown_flavor(),
272 }
273 }
274}
275
276#[tower_lsp::async_trait]
277impl LanguageServer for RumdlLanguageServer {
278 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
279 log::info!("Initializing rumdl Language Server");
280
281 if let Some(options) = params.initialization_options
283 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
284 {
285 *self.config.write().await = config;
286 }
287
288 let supports_pull = params
291 .capabilities
292 .text_document
293 .as_ref()
294 .and_then(|td| td.diagnostic.as_ref())
295 .is_some();
296
297 if supports_pull {
298 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
299 *self.client_supports_pull_diagnostics.write().await = true;
300 } else {
301 log::info!("Client does not support pull diagnostics - using push model");
302 }
303
304 let supports_hierarchical_symbols = params
307 .capabilities
308 .text_document
309 .as_ref()
310 .and_then(|td| td.document_symbol.as_ref())
311 .and_then(|ds| ds.hierarchical_document_symbol_support)
312 .unwrap_or(false);
313 *self.client_supports_hierarchical_symbols.write().await = supports_hierarchical_symbols;
314
315 let mut roots = Vec::new();
317 if let Some(workspace_folders) = params.workspace_folders {
318 for folder in workspace_folders {
319 if let Ok(path) = folder.uri.to_file_path() {
320 let path = super::resolve_workspace_root(&path);
321 log::info!("Workspace root: {}", path.display());
322 roots.push(path);
323 }
324 }
325 } else if let Some(root_uri) = params.root_uri
326 && let Ok(path) = root_uri.to_file_path()
327 {
328 let path = super::resolve_workspace_root(&path);
329 log::info!("Workspace root: {}", path.display());
330 roots.push(path);
331 }
332 *self.workspace_roots.write().await = roots;
333
334 self.load_configuration(false).await;
336
337 let (enable_link_navigation, enable_link_completions, enable_symbols) = {
338 let config = self.config.read().await;
339 (
340 config.enable_link_navigation,
341 config.enable_link_completions,
342 config.enable_symbols,
343 )
344 };
345
346 Ok(InitializeResult {
347 capabilities: ServerCapabilities {
348 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
349 open_close: Some(true),
350 change: Some(TextDocumentSyncKind::FULL),
351 will_save: Some(false),
352 will_save_wait_until: Some(true),
353 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
354 include_text: Some(false),
355 })),
356 })),
357 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
358 code_action_kinds: Some(vec![
359 CodeActionKind::QUICKFIX,
360 CodeActionKind::SOURCE_FIX_ALL,
361 CodeActionKind::new("source.fixAll.rumdl"),
362 ]),
363 work_done_progress_options: WorkDoneProgressOptions::default(),
364 resolve_provider: None,
365 })),
366 document_formatting_provider: Some(OneOf::Left(true)),
367 document_range_formatting_provider: Some(OneOf::Left(true)),
368 document_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
369 workspace_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
370 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
371 identifier: Some("rumdl".to_string()),
372 inter_file_dependencies: true,
373 workspace_diagnostics: false,
374 work_done_progress_options: WorkDoneProgressOptions::default(),
375 })),
376 completion_provider: Some(CompletionOptions {
382 trigger_characters: Some(if enable_link_completions {
383 vec![
384 "`".to_string(),
385 "(".to_string(),
386 "#".to_string(),
387 "/".to_string(),
388 ".".to_string(),
389 "-".to_string(),
390 ]
391 } else {
392 vec!["`".to_string()]
393 }),
394 resolve_provider: Some(false),
395 work_done_progress_options: WorkDoneProgressOptions::default(),
396 all_commit_characters: None,
397 completion_item: None,
398 }),
399 definition_provider: enable_link_navigation.then_some(OneOf::Left(true)),
400 references_provider: enable_link_navigation.then_some(OneOf::Left(true)),
401 hover_provider: enable_link_navigation.then_some(HoverProviderCapability::Simple(true)),
402 rename_provider: enable_link_navigation.then_some(OneOf::Right(RenameOptions {
403 prepare_provider: Some(true),
404 work_done_progress_options: WorkDoneProgressOptions::default(),
405 })),
406 workspace: Some(WorkspaceServerCapabilities {
407 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
408 supported: Some(true),
409 change_notifications: Some(OneOf::Left(true)),
410 }),
411 file_operations: None,
412 }),
413 ..Default::default()
414 },
415 server_info: Some(ServerInfo {
416 name: "rumdl".to_string(),
417 version: Some(env!("CARGO_PKG_VERSION").to_string()),
418 }),
419 })
420 }
421
422 async fn initialized(&self, _: InitializedParams) {
423 let version = env!("CARGO_PKG_VERSION");
424
425 let (binary_path, build_time) = std::env::current_exe().ok().map_or_else(
427 || ("unknown".to_string(), "unknown".to_string()),
428 |path| {
429 let path_str = path.to_str().unwrap_or("unknown").to_string();
430 let build_time = std::fs::metadata(&path)
431 .ok()
432 .and_then(|metadata| metadata.modified().ok())
433 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
434 .and_then(|duration| {
435 let secs = duration.as_secs();
436 chrono::DateTime::from_timestamp(secs as i64, 0)
437 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
438 })
439 .unwrap_or_else(|| "unknown".to_string());
440 (path_str, build_time)
441 },
442 );
443
444 let working_dir = std::env::current_dir()
445 .ok()
446 .and_then(|p| p.to_str().map(std::string::ToString::to_string))
447 .unwrap_or_else(|| "unknown".to_string());
448
449 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
450 log::info!("Working directory: {working_dir}");
451
452 self.client
453 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
454 .await;
455
456 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
458 log::warn!("Failed to trigger initial workspace indexing");
459 } else {
460 log::info!("Triggered initial workspace indexing for cross-file analysis");
461 }
462
463 let markdown_patterns = [
465 "**/*.md",
466 "**/*.markdown",
467 "**/*.mdx",
468 "**/*.mkd",
469 "**/*.mkdn",
470 "**/*.mdown",
471 "**/*.mdwn",
472 "**/*.qmd",
473 "**/*.rmd",
474 ];
475 let config_patterns = [
479 "**/.rumdl.toml",
480 "**/rumdl.toml",
481 "**/pyproject.toml",
482 "**/.markdownlint.json",
483 "**/.markdownlint-cli2.yaml",
484 "**/.markdownlint-cli2.jsonc",
485 "**/.editorconfig",
486 ];
487 let watchers: Vec<_> = markdown_patterns
488 .iter()
489 .chain(config_patterns.iter())
490 .map(|pattern| FileSystemWatcher {
491 glob_pattern: GlobPattern::String((*pattern).to_string()),
492 kind: Some(WatchKind::all()),
493 })
494 .collect();
495
496 let registration = Registration {
497 id: "markdown-watcher".to_string(),
498 method: "workspace/didChangeWatchedFiles".to_string(),
499 register_options: Some(
500 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
501 ),
502 };
503
504 if self.client.register_capability(vec![registration]).await.is_err() {
505 log::debug!("Client does not support file watching capability");
506 }
507 }
508
509 async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
510 let uri = params.text_document_position.text_document.uri;
511 let position = params.text_document_position.position;
512
513 let Some(text) = self.get_document_content(&uri).await else {
515 return Ok(None);
516 };
517
518 if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
520 log::debug!(
521 "Code fence completion triggered at {}:{}, current text: '{}'",
522 position.line,
523 position.character,
524 current_text
525 );
526 let items = self
527 .get_language_completions(&uri, ¤t_text, start_col, position)
528 .await;
529 if !items.is_empty() {
530 return Ok(Some(CompletionResponse::Array(items)));
531 }
532 }
533
534 if self.config.read().await.enable_link_completions {
536 let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
540 let skip_link_check = matches!(trigger, Some("." | "-")) && {
541 let line_num = position.line as usize;
542 !text.lines().nth(line_num).is_some_and(|line| line.contains("]("))
545 };
546
547 if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
548 if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
549 log::debug!(
550 "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
551 position.line,
552 position.character,
553 link_info.file_path,
554 partial_anchor
555 );
556 let items = self
557 .get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
558 .await;
559 if !items.is_empty() {
560 return Ok(Some(CompletionResponse::Array(items)));
561 }
562 } else {
563 log::debug!(
564 "File path completion triggered at {}:{}, partial: '{}'",
565 position.line,
566 position.character,
567 link_info.file_path
568 );
569 let list = self
570 .get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
571 .await;
572 if !list.items.is_empty() {
573 return Ok(Some(CompletionResponse::List(list)));
574 }
575 }
576 }
577 }
578
579 Ok(None)
580 }
581
582 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
583 let mut roots = self.workspace_roots.write().await;
585
586 for removed in ¶ms.event.removed {
590 if let Ok(path) = removed.uri.to_file_path() {
591 let path = super::resolve_workspace_root(&path);
592 roots.retain(|r| r != &path);
593 log::info!("Removed workspace root: {}", path.display());
594 }
595 }
596
597 for added in ¶ms.event.added {
599 if let Ok(path) = added.uri.to_file_path()
600 && let path = super::resolve_workspace_root(&path)
601 && !roots.contains(&path)
602 {
603 log::info!("Added workspace root: {}", path.display());
604 roots.push(path);
605 }
606 }
607 drop(roots);
608
609 self.config_cache.write().await.clear();
611
612 self.reload_configuration().await;
614
615 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
617 log::warn!("Failed to trigger workspace rescan after folder change");
618 }
619 }
620
621 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
622 log::debug!("Configuration changed: {:?}", params.settings);
623
624 let settings_value = params.settings;
628
629 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
631 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
632 } else {
633 settings_value
634 };
635
636 let has_content_roots_key = matches!(
640 &rumdl_settings,
641 serde_json::Value::Object(obj) if obj.contains_key("linkCompletionContentRoots")
642 );
643
644 let has_symbols_key = matches!(
649 &rumdl_settings,
650 serde_json::Value::Object(obj) if obj.contains_key("enableSymbols")
651 );
652
653 let mut config_applied = false;
655 let mut warnings: Vec<String> = Vec::new();
656
657 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
661 && (rule_settings.disable.is_some()
662 || rule_settings.enable.is_some()
663 || rule_settings.line_length.is_some()
664 || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
665 {
666 if let Some(ref disable) = rule_settings.disable {
668 for rule in disable {
669 if !is_valid_rule_name(rule) {
670 warnings.push(format!("Unknown rule in disable list: {rule}"));
671 }
672 }
673 }
674 if let Some(ref enable) = rule_settings.enable {
675 for rule in enable {
676 if !is_valid_rule_name(rule) {
677 warnings.push(format!("Unknown rule in enable list: {rule}"));
678 }
679 }
680 }
681 for rule_name in rule_settings.rules.keys() {
683 if !is_valid_rule_name(rule_name) {
684 warnings.push(format!("Unknown rule in settings: {rule_name}"));
685 }
686 }
687
688 log::info!("Applied rule settings from configuration (Neovim style)");
689 let mut config = self.config.write().await;
690 config.settings = Some(rule_settings);
691 drop(config);
692 config_applied = true;
693 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
694 && (full_config.config_path.is_some()
695 || full_config.enable_rules.is_some()
696 || full_config.disable_rules.is_some()
697 || full_config.settings.is_some()
698 || !full_config.enable_linting
699 || full_config.enable_auto_fix
700 || !full_config.enable_link_completions
701 || !full_config.enable_link_navigation
702 || has_symbols_key
703 || has_content_roots_key)
704 {
705 if let Some(ref rules) = full_config.enable_rules {
707 for rule in rules {
708 if !is_valid_rule_name(rule) {
709 warnings.push(format!("Unknown rule in enableRules: {rule}"));
710 }
711 }
712 }
713 if let Some(ref rules) = full_config.disable_rules {
714 for rule in rules {
715 if !is_valid_rule_name(rule) {
716 warnings.push(format!("Unknown rule in disableRules: {rule}"));
717 }
718 }
719 }
720
721 {
729 let mut config = self.config.write().await;
730 if let Some(merged) = merge_lsp_config(&config, &rumdl_settings) {
731 *config = merged;
732 drop(config);
733 log::info!("Merged LSP configuration from client settings");
734 config_applied = true;
735 } else {
736 drop(config);
737 warnings.push("Could not merge LSP configuration update; keeping current settings".to_string());
738 }
739 }
740 } else if let serde_json::Value::Object(obj) = rumdl_settings {
741 let mut config = self.config.write().await;
744
745 let mut rules = std::collections::HashMap::new();
747 let mut disable = Vec::new();
748 let mut enable = Vec::new();
749 let mut line_length = None;
750
751 for (key, value) in obj {
752 match key.as_str() {
753 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
754 Ok(d) => {
755 if d.len() > MAX_RULE_LIST_SIZE {
756 warnings.push(format!(
757 "Too many rules in 'disable' ({} > {}), truncating",
758 d.len(),
759 MAX_RULE_LIST_SIZE
760 ));
761 }
762 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
763 if !is_valid_rule_name(rule) {
764 warnings.push(format!("Unknown rule in disable: {rule}"));
765 }
766 }
767 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
768 }
769 Err(_) => {
770 warnings.push(format!(
771 "Invalid 'disable' value: expected array of strings, got {value}"
772 ));
773 }
774 },
775 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
776 Ok(e) => {
777 if e.len() > MAX_RULE_LIST_SIZE {
778 warnings.push(format!(
779 "Too many rules in 'enable' ({} > {}), truncating",
780 e.len(),
781 MAX_RULE_LIST_SIZE
782 ));
783 }
784 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
785 if !is_valid_rule_name(rule) {
786 warnings.push(format!("Unknown rule in enable: {rule}"));
787 }
788 }
789 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
790 }
791 Err(_) => {
792 warnings.push(format!(
793 "Invalid 'enable' value: expected array of strings, got {value}"
794 ));
795 }
796 },
797 "lineLength" | "line_length" | "line-length" => {
798 if let Some(l) = value.as_u64() {
799 match usize::try_from(l) {
800 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
801 Ok(len) => warnings.push(format!(
802 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
803 )),
804 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
805 }
806 } else {
807 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
808 }
809 }
810 _ if key.starts_with("MD") || key.starts_with("md") => {
812 let normalized = key.to_uppercase();
813 if !is_valid_rule_name(&normalized) {
814 warnings.push(format!("Unknown rule: {key}"));
815 }
816 rules.insert(normalized, value);
817 }
818 _ => {
819 warnings.push(format!("Unknown configuration key: {key}"));
821 }
822 }
823 }
824
825 let settings = LspRuleSettings {
826 line_length,
827 disable: if disable.is_empty() { None } else { Some(disable) },
828 enable: if enable.is_empty() { None } else { Some(enable) },
829 rules,
830 };
831
832 log::info!("Applied Neovim-style rule settings (manual parse)");
833 config.settings = Some(settings);
834 drop(config);
835 config_applied = true;
836 } else {
837 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
838 }
839
840 for warning in &warnings {
842 log::warn!("{warning}");
843 }
844
845 if !warnings.is_empty() {
847 let message = if warnings.len() == 1 {
848 format!("rumdl: {}", warnings[0])
849 } else {
850 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
851 };
852 self.client.log_message(MessageType::WARNING, message).await;
853 }
854
855 if !config_applied {
856 log::debug!("No configuration changes applied");
857 }
858
859 self.config_cache.write().await.clear();
861
862 if config_applied {
870 self.load_configuration(false).await;
871
872 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
876 log::warn!("Failed to request workspace rescan after configuration change");
877 }
878 }
879
880 let doc_list: Vec<_> = {
882 let documents = self.documents.read().await;
883 documents
884 .iter()
885 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
886 .collect()
887 };
888
889 let tasks = doc_list.into_iter().map(|(uri, text)| {
891 let server = self.clone();
892 tokio::spawn(async move {
893 server.update_diagnostics(uri, text, true).await;
894 })
895 });
896
897 let _ = join_all(tasks).await;
899 }
900
901 async fn shutdown(&self) -> JsonRpcResult<()> {
902 log::info!("Shutting down rumdl Language Server");
903
904 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
906
907 Ok(())
908 }
909
910 async fn did_open(&self, params: DidOpenTextDocumentParams) {
911 let uri = params.text_document.uri;
912 let text = params.text_document.text;
913 let version = params.text_document.version;
914
915 let entry = DocumentEntry {
916 content: text.clone(),
917 version: Some(version),
918 from_disk: false,
919 };
920 self.documents.write().await.insert(uri.clone(), entry);
921
922 let resolved = super::resolve_uri_spelling(&uri);
924 if resolved != uri {
925 let mut aliases = self.document_aliases.write().await;
926 let spellings = aliases.entry(resolved).or_default();
927 if !spellings.contains(&uri) {
928 spellings.push(uri.clone());
929 }
930 }
931
932 if let Some(path) = super::resolve_uri(&uri) {
934 let _ = self
935 .update_tx
936 .send(IndexUpdate::FileChanged {
937 path,
938 content: text.clone(),
939 })
940 .await;
941 }
942
943 self.update_diagnostics(uri, text, true).await;
944 }
945
946 async fn did_change(&self, params: DidChangeTextDocumentParams) {
947 let uri = params.text_document.uri;
948 let version = params.text_document.version;
949
950 if let Some(change) = params.content_changes.into_iter().next() {
951 let text = change.text;
952
953 let entry = DocumentEntry {
954 content: text.clone(),
955 version: Some(version),
956 from_disk: false,
957 };
958 self.documents.write().await.insert(uri.clone(), entry);
959
960 if let Some(path) = super::resolve_uri(&uri) {
962 let _ = self
963 .update_tx
964 .send(IndexUpdate::FileChanged {
965 path,
966 content: text.clone(),
967 })
968 .await;
969 }
970
971 self.update_diagnostics(uri, text, false).await;
972 }
973 }
974
975 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
976 if params.reason != TextDocumentSaveReason::MANUAL {
979 return Ok(None);
980 }
981
982 let config_guard = self.config.read().await;
983 let enable_auto_fix = config_guard.enable_auto_fix;
984 drop(config_guard);
985
986 if !enable_auto_fix {
987 return Ok(None);
988 }
989
990 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
992 return Ok(None);
993 };
994
995 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
997 Ok(Some(fixed_text)) => {
998 Ok(Some(vec![TextEdit {
1000 range: Range {
1001 start: Position { line: 0, character: 0 },
1002 end: self.get_end_position(&text),
1003 },
1004 new_text: fixed_text,
1005 }]))
1006 }
1007 Ok(None) => Ok(None),
1008 Err(e) => {
1009 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1010 Ok(None)
1011 }
1012 }
1013 }
1014
1015 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1016 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1019 self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
1020 .await;
1021 }
1022 }
1023
1024 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1025 self.documents.write().await.remove(¶ms.text_document.uri);
1027 let resolved = super::resolve_uri_spelling(¶ms.text_document.uri);
1030 if resolved != params.text_document.uri {
1031 let mut aliases = self.document_aliases.write().await;
1032 if let Some(spellings) = aliases.get_mut(&resolved) {
1033 spellings.retain(|u| u != ¶ms.text_document.uri);
1034 if spellings.is_empty() {
1035 aliases.remove(&resolved);
1036 }
1037 }
1038 }
1039
1040 self.client
1043 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1044 .await;
1045 }
1046
1047 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1048 const CONFIG_FILES: &[&str] = &[
1050 ".rumdl.toml",
1051 "rumdl.toml",
1052 "pyproject.toml",
1053 ".markdownlint.json",
1054 ".markdownlint-cli2.jsonc",
1055 ".markdownlint-cli2.yaml",
1056 ".markdownlint-cli2.yml",
1057 ];
1058
1059 let mut config_changed = false;
1060 let reads_editorconfig = self.reads_editorconfig().await;
1063
1064 for change in ¶ms.changes {
1065 if let Some(path) = super::resolve_uri(&change.uri) {
1069 let file_name = path.file_name().and_then(|f| f.to_str());
1070
1071 if let Some(name) = file_name
1073 && (CONFIG_FILES.contains(&name) || (reads_editorconfig && name == ".editorconfig"))
1074 && !config_changed
1075 {
1076 log::info!("Config file changed: {}, invalidating config cache", path.display());
1077
1078 let mut cache = self.config_cache.write().await;
1082 cache.clear();
1083
1084 drop(cache);
1086 self.reload_configuration().await;
1087 config_changed = true;
1088 }
1089
1090 if let Some(ext) = path.extension()
1092 && is_markdown_extension(ext)
1093 {
1094 match change.typ {
1095 FileChangeType::CREATED | FileChangeType::CHANGED => {
1096 let roots = self.workspace_roots.read().await.clone();
1101 let (options, excludes) = {
1102 let config = self.rumdl_config.read().await;
1103 (
1104 crate::lsp::index_worker::index_walk_options(&config),
1105 ExcludeMatchers::new(&config.global.exclude),
1106 )
1107 };
1108 if crate::lsp::index_worker::path_is_ignored_for_index(&roots, &path, &options, &excludes) {
1109 let _ = self
1114 .update_tx
1115 .send(IndexUpdate::FileDeleted { path: path.clone() })
1116 .await;
1117 continue;
1118 }
1119 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1121 let _ = self
1122 .update_tx
1123 .send(IndexUpdate::FileChanged {
1124 path: path.clone(),
1125 content,
1126 })
1127 .await;
1128 }
1129 }
1130 FileChangeType::DELETED => {
1131 let _ = self
1132 .update_tx
1133 .send(IndexUpdate::FileDeleted { path: path.clone() })
1134 .await;
1135 }
1136 _ => {}
1137 }
1138 }
1139 }
1140 }
1141
1142 if config_changed {
1144 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1148 log::warn!("Failed to request workspace rescan after config change");
1149 }
1150
1151 let docs_to_update: Vec<(Url, String)> = {
1152 let docs = self.documents.read().await;
1153 docs.iter()
1154 .filter(|(_, entry)| !entry.from_disk)
1155 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1156 .collect()
1157 };
1158
1159 for (uri, text) in docs_to_update {
1160 self.update_diagnostics(uri, text, true).await;
1161 }
1162 }
1163 }
1164
1165 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1166 let uri = params.text_document.uri;
1167 let range = params.range;
1168 let requested_kinds = params.context.only;
1169
1170 if let Some(text) = self.get_document_content(&uri).await {
1171 match self.get_code_actions(&uri, &text, range).await {
1172 Ok(actions) => {
1173 let filtered_actions = if let Some(ref kinds) = requested_kinds
1177 && !kinds.is_empty()
1178 {
1179 actions
1180 .into_iter()
1181 .filter(|action| {
1182 action.kind.as_ref().is_some_and(|action_kind| {
1183 let action_kind_str = action_kind.as_str();
1184 kinds.iter().any(|requested| {
1185 let requested_str = requested.as_str();
1186 action_kind_str.starts_with(requested_str)
1189 })
1190 })
1191 })
1192 .collect()
1193 } else {
1194 actions
1195 };
1196
1197 let response: Vec<CodeActionOrCommand> = filtered_actions
1198 .into_iter()
1199 .map(CodeActionOrCommand::CodeAction)
1200 .collect();
1201 Ok(Some(response))
1202 }
1203 Err(e) => {
1204 log::error!("Failed to get code actions: {e}");
1205 Ok(None)
1206 }
1207 }
1208 } else {
1209 Ok(None)
1210 }
1211 }
1212
1213 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1214 log::debug!(
1219 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1220 params.range
1221 );
1222
1223 let formatting_params = DocumentFormattingParams {
1224 text_document: params.text_document,
1225 options: params.options,
1226 work_done_progress_params: params.work_done_progress_params,
1227 };
1228
1229 self.formatting(formatting_params).await
1230 }
1231
1232 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1233 let uri = params.text_document.uri;
1234 let options = params.options;
1235
1236 log::debug!("Formatting request for: {uri}");
1237 log::debug!(
1238 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1239 options.insert_final_newline,
1240 options.trim_final_newlines,
1241 options.trim_trailing_whitespace
1242 );
1243
1244 if let Some(text) = self.get_document_content(&uri).await {
1245 let mut result = match self.apply_all_fixes(&uri, &text).await {
1254 Ok(Some(fixed)) => fixed,
1255 Ok(None) => text.clone(),
1256 Err(e) => {
1257 log::error!("Failed to apply fixes during formatting: {e}");
1258 text.clone()
1259 }
1260 };
1261
1262 result = Self::apply_formatting_options(result, &options);
1265
1266 if result != text {
1268 log::debug!("Returning formatting edits");
1269 let end_position = self.get_end_position(&text);
1270 let edit = TextEdit {
1271 range: Range {
1272 start: Position { line: 0, character: 0 },
1273 end: end_position,
1274 },
1275 new_text: result,
1276 };
1277 return Ok(Some(vec![edit]));
1278 }
1279
1280 Ok(Some(Vec::new()))
1281 } else {
1282 log::warn!("Document not found: {uri}");
1283 Ok(None)
1284 }
1285 }
1286
1287 async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1288 if !self.config.read().await.enable_link_navigation {
1289 return Ok(None);
1290 }
1291 let uri = params.text_document_position_params.text_document.uri;
1292 let position = params.text_document_position_params.position;
1293
1294 log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1295
1296 Ok(self.handle_goto_definition(&uri, position).await)
1297 }
1298
1299 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1300 if !self.config.read().await.enable_link_navigation {
1301 return Ok(None);
1302 }
1303 let uri = params.text_document_position.text_document.uri;
1304 let position = params.text_document_position.position;
1305
1306 log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1307
1308 Ok(self.handle_references(&uri, position).await)
1309 }
1310
1311 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1312 if !self.config.read().await.enable_link_navigation {
1313 return Ok(None);
1314 }
1315 let uri = params.text_document_position_params.text_document.uri;
1316 let position = params.text_document_position_params.position;
1317
1318 log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1319
1320 Ok(self.handle_hover(&uri, position).await)
1321 }
1322
1323 async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1324 if !self.config.read().await.enable_link_navigation {
1325 return Ok(None);
1326 }
1327 let uri = params.text_document.uri;
1328 let position = params.position;
1329
1330 log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1331
1332 Ok(self.handle_prepare_rename(&uri, position).await)
1333 }
1334
1335 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1336 if !self.config.read().await.enable_link_navigation {
1337 return Ok(None);
1338 }
1339 let uri = params.text_document_position.text_document.uri;
1340 let position = params.text_document_position.position;
1341 let new_name = params.new_name;
1342
1343 log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1344
1345 Ok(self.handle_rename(&uri, position, &new_name).await)
1346 }
1347
1348 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1349 let uri = params.text_document.uri;
1350
1351 if let Some(text) = self.get_open_document_content(&uri).await {
1352 match self.lint_document(&uri, &text, true).await {
1353 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1354 RelatedFullDocumentDiagnosticReport {
1355 related_documents: None,
1356 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1357 result_id: None,
1358 items: diagnostics,
1359 },
1360 },
1361 ))),
1362 Err(e) => {
1363 log::error!("Failed to get diagnostics: {e}");
1364 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1365 RelatedFullDocumentDiagnosticReport {
1366 related_documents: None,
1367 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1368 result_id: None,
1369 items: Vec::new(),
1370 },
1371 },
1372 )))
1373 }
1374 }
1375 } else {
1376 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1377 RelatedFullDocumentDiagnosticReport {
1378 related_documents: None,
1379 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1380 result_id: None,
1381 items: Vec::new(),
1382 },
1383 },
1384 )))
1385 }
1386 }
1387
1388 async fn document_symbol(&self, params: DocumentSymbolParams) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
1389 if !self.config.read().await.enable_symbols {
1390 return Ok(None);
1391 }
1392
1393 let uri = params.text_document.uri;
1394 let Some(text) = self.get_document_content(&uri).await else {
1395 return Ok(None);
1396 };
1397
1398 let flavor = self.resolve_flavor_for_uri(&uri).await;
1399 let ctx = crate::lint_context::LintContext::new(&text, flavor, None);
1400
1401 if *self.client_supports_hierarchical_symbols.read().await {
1402 let symbols = super::symbols::document_symbols(&ctx);
1403 Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Nested(symbols)))
1404 } else {
1405 let symbols = super::symbols::document_symbols_flat(&ctx, &uri);
1406 Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Flat(symbols)))
1407 }
1408 }
1409
1410 async fn symbol(&self, params: WorkspaceSymbolParams) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
1411 if !self.config.read().await.enable_symbols {
1412 return Ok(None);
1413 }
1414
1415 let query = params.query.to_lowercase();
1416 let index = self.workspace_index.read().await;
1417 let symbols = super::symbols::workspace_symbols(&index, &query);
1418 Ok(if symbols.is_empty() { None } else { Some(symbols) })
1419 }
1420}
1421
1422#[cfg(test)]
1423#[path = "tests.rs"]
1424mod tests;