1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use tokio::sync::{RwLock, mpsc};
11use tower_lsp::jsonrpc::Result as JsonRpcResult;
12use tower_lsp::lsp_types::*;
13use tower_lsp::{Client, LanguageServer};
14
15use crate::config::{Config, ConfigValidated, SourcedConfig, is_valid_rule_name};
16use crate::discovery::{ExcludeMatchers, is_markdown_extension};
17use crate::lsp::index_worker::{IndexWorker, SharedIndexState};
18use crate::lsp::types::{IndexState, IndexUpdate, LspRuleSettings, RelintRequest, RumdlLspConfig};
19use crate::workspace_index::WorkspaceIndex;
20
21const MAX_RULE_LIST_SIZE: usize = 100;
23
24const MAX_LINE_LENGTH: usize = 10_000;
26
27fn merge_lsp_config(current: &RumdlLspConfig, incoming: &serde_json::Value) -> Option<RumdlLspConfig> {
39 let serde_json::Value::Object(incoming) = incoming else {
40 return None;
41 };
42 let serde_json::Value::Object(mut base) = serde_json::to_value(current).ok()? else {
43 return None;
44 };
45 for (key, value) in incoming {
46 base.insert(key.clone(), value.clone());
47 }
48 serde_json::from_value(serde_json::Value::Object(base)).ok()
49}
50
51#[derive(Clone, Debug, PartialEq)]
53pub(crate) struct DocumentEntry {
54 pub(crate) content: String,
56 pub(crate) version: Option<i32>,
58 pub(crate) from_disk: bool,
60}
61
62#[derive(Clone, Debug)]
64pub(crate) struct ConfigCacheEntry {
65 pub(crate) config: Config,
67 pub(crate) sourced: Option<Arc<SourcedConfig<ConfigValidated>>>,
72 pub(crate) config_file: Option<PathBuf>,
74 pub(crate) from_global_fallback: bool,
76}
77
78#[derive(Clone)]
88pub struct RumdlLanguageServer {
89 pub(crate) client: Client,
90 pub(crate) config: Arc<RwLock<RumdlLspConfig>>,
92 pub(crate) rumdl_config: Arc<RwLock<Config>>,
94 pub(crate) rumdl_sourced: Arc<RwLock<Option<Arc<SourcedConfig<ConfigValidated>>>>>,
97 pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
99 pub(crate) document_aliases: Arc<RwLock<HashMap<Url, Vec<Url>>>>,
112 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
114 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
117 pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
119 pub(crate) index_state: Arc<RwLock<IndexState>>,
121 update_tx: Option<mpsc::Sender<IndexUpdate>>,
129 pub(crate) client_supports_pull_diagnostics: Arc<RwLock<bool>>,
132 pub(crate) client_supports_hierarchical_symbols: Arc<RwLock<bool>>,
136 pub(crate) cli_config_path: Option<String>,
144}
145
146impl RumdlLanguageServer {
147 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
148 let initial_config = RumdlLspConfig::default();
149 let cli_config_path = cli_config_path.map(str::to_string);
150
151 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
153 let index_state = Arc::new(RwLock::new(IndexState::default()));
154 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
155 let rumdl_config = Arc::new(RwLock::new(Config::default()));
156 let documents = Arc::new(RwLock::new(HashMap::new()));
157
158 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
160 let (relint_tx, relint_rx) = mpsc::channel::<RelintRequest>(100);
161
162 let worker = IndexWorker::new(
164 update_rx,
165 client.clone(),
166 relint_tx,
167 SharedIndexState {
168 workspace_index: workspace_index.clone(),
169 index_state: index_state.clone(),
170 workspace_roots: workspace_roots.clone(),
171 rumdl_config: rumdl_config.clone(),
172 documents: documents.clone(),
173 },
174 );
175 tokio::spawn(worker.run());
176
177 let server = Self {
178 client,
179 config: Arc::new(RwLock::new(initial_config)),
180 rumdl_config,
181 rumdl_sourced: Arc::new(RwLock::new(None)),
182 documents,
183 document_aliases: Arc::new(RwLock::new(HashMap::new())),
184 workspace_roots,
185 config_cache: Arc::new(RwLock::new(HashMap::new())),
186 workspace_index,
187 index_state,
188 update_tx: Some(update_tx),
189 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
190 client_supports_hierarchical_symbols: Arc::new(RwLock::new(false)),
191 cli_config_path,
192 };
193
194 tokio::spawn(server.detached_for_background().run_relint_worker(relint_rx));
199
200 server
201 }
202
203 fn detached_for_background(&self) -> Self {
214 Self {
215 update_tx: None,
216 ..self.clone()
217 }
218 }
219
220 pub(crate) async fn queue_index_update(&self, update: IndexUpdate) -> bool {
226 let Some(update_tx) = &self.update_tx else {
227 return false;
228 };
229 update_tx.send(update).await.is_ok()
230 }
231
232 pub(super) async fn get_document_content(&self, uri: &Url) -> Option<String> {
238 let uri = &self.store_uri(uri).await;
239
240 {
242 let docs = self.documents.read().await;
243 if let Some(entry) = docs.get(uri) {
244 return Some(entry.content.clone());
245 }
246 }
247
248 if let Ok(path) = uri.to_file_path() {
250 if let Ok(content) = tokio::fs::read_to_string(&path).await {
251 let entry = DocumentEntry {
253 content: content.clone(),
254 version: None,
255 from_disk: true,
256 };
257
258 let mut docs = self.documents.write().await;
259 docs.insert(uri.clone(), entry);
260
261 log::debug!("Loaded document from disk and cached: {uri}");
262 return Some(content);
263 } else {
264 log::debug!("Failed to read file from disk: {uri}");
265 }
266 }
267
268 None
269 }
270
271 async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
277 let uri = self.store_uri(uri).await;
278 let docs = self.documents.read().await;
279 docs.get(&uri)
280 .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
281 }
282
283 async fn store_uri(&self, uri: &Url) -> Url {
295 let Some(spellings) = self.document_aliases.read().await.get(uri).cloned() else {
296 return uri.clone();
297 };
298 let docs = self.documents.read().await;
299 let is_open = |u: &Url| matches!(docs.get(u), Some(entry) if !entry.from_disk);
300 if is_open(uri) {
301 return uri.clone();
302 }
303 spellings
305 .iter()
306 .rev()
307 .find(|u| is_open(u))
308 .cloned()
309 .unwrap_or_else(|| uri.clone())
310 }
311
312 pub(super) async fn resolve_flavor_for_uri(&self, uri: &Url) -> crate::config::MarkdownFlavor {
315 match super::resolve_uri(uri) {
316 Some(path) => self.resolve_config_for_file(&path).await.get_flavor_for_file(&path),
317 None => self.rumdl_config.read().await.markdown_flavor(),
318 }
319 }
320}
321
322#[tower_lsp::async_trait]
323impl LanguageServer for RumdlLanguageServer {
324 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
325 log::info!("Initializing rumdl Language Server");
326
327 if let Some(options) = params.initialization_options
329 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
330 {
331 *self.config.write().await = config;
332 }
333
334 let supports_pull = params
337 .capabilities
338 .text_document
339 .as_ref()
340 .and_then(|td| td.diagnostic.as_ref())
341 .is_some();
342
343 if supports_pull {
344 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
345 *self.client_supports_pull_diagnostics.write().await = true;
346 } else {
347 log::info!("Client does not support pull diagnostics - using push model");
348 }
349
350 let supports_hierarchical_symbols = params
353 .capabilities
354 .text_document
355 .as_ref()
356 .and_then(|td| td.document_symbol.as_ref())
357 .and_then(|ds| ds.hierarchical_document_symbol_support)
358 .unwrap_or(false);
359 *self.client_supports_hierarchical_symbols.write().await = supports_hierarchical_symbols;
360
361 let mut roots = Vec::new();
363 if let Some(workspace_folders) = params.workspace_folders {
364 for folder in workspace_folders {
365 if let Ok(path) = folder.uri.to_file_path() {
366 let path = super::resolve_workspace_root(&path);
367 log::info!("Workspace root: {}", path.display());
368 roots.push(path);
369 }
370 }
371 } else if let Some(root_uri) = params.root_uri
372 && let Ok(path) = root_uri.to_file_path()
373 {
374 let path = super::resolve_workspace_root(&path);
375 log::info!("Workspace root: {}", path.display());
376 roots.push(path);
377 }
378 *self.workspace_roots.write().await = roots;
379
380 self.load_configuration(false).await;
382
383 let (enable_link_navigation, enable_link_completions, enable_symbols) = {
384 let config = self.config.read().await;
385 (
386 config.enable_link_navigation,
387 config.enable_link_completions,
388 config.enable_symbols,
389 )
390 };
391
392 Ok(InitializeResult {
393 capabilities: ServerCapabilities {
394 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
395 open_close: Some(true),
396 change: Some(TextDocumentSyncKind::FULL),
397 will_save: Some(false),
398 will_save_wait_until: Some(true),
399 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
400 include_text: Some(false),
401 })),
402 })),
403 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
404 code_action_kinds: Some(vec![
405 CodeActionKind::QUICKFIX,
406 CodeActionKind::SOURCE_FIX_ALL,
407 CodeActionKind::new("source.fixAll.rumdl"),
408 ]),
409 work_done_progress_options: WorkDoneProgressOptions::default(),
410 resolve_provider: None,
411 })),
412 document_formatting_provider: Some(OneOf::Left(true)),
413 document_range_formatting_provider: Some(OneOf::Left(true)),
414 document_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
415 workspace_symbol_provider: enable_symbols.then_some(OneOf::Left(true)),
416 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
417 identifier: Some("rumdl".to_string()),
418 inter_file_dependencies: true,
419 workspace_diagnostics: false,
420 work_done_progress_options: WorkDoneProgressOptions::default(),
421 })),
422 completion_provider: Some(CompletionOptions {
428 trigger_characters: Some(if enable_link_completions {
429 vec![
430 "`".to_string(),
431 "(".to_string(),
432 "#".to_string(),
433 "/".to_string(),
434 ".".to_string(),
435 "-".to_string(),
436 ]
437 } else {
438 vec!["`".to_string()]
439 }),
440 resolve_provider: Some(false),
441 work_done_progress_options: WorkDoneProgressOptions::default(),
442 all_commit_characters: None,
443 completion_item: None,
444 }),
445 definition_provider: enable_link_navigation.then_some(OneOf::Left(true)),
446 references_provider: enable_link_navigation.then_some(OneOf::Left(true)),
447 hover_provider: enable_link_navigation.then_some(HoverProviderCapability::Simple(true)),
448 rename_provider: enable_link_navigation.then_some(OneOf::Right(RenameOptions {
449 prepare_provider: Some(true),
450 work_done_progress_options: WorkDoneProgressOptions::default(),
451 })),
452 workspace: Some(WorkspaceServerCapabilities {
453 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
454 supported: Some(true),
455 change_notifications: Some(OneOf::Left(true)),
456 }),
457 file_operations: None,
458 }),
459 ..Default::default()
460 },
461 server_info: Some(ServerInfo {
462 name: "rumdl".to_string(),
463 version: Some(env!("CARGO_PKG_VERSION").to_string()),
464 }),
465 })
466 }
467
468 async fn initialized(&self, _: InitializedParams) {
469 let version = env!("CARGO_PKG_VERSION");
470
471 let (binary_path, build_time) = std::env::current_exe().ok().map_or_else(
473 || ("unknown".to_string(), "unknown".to_string()),
474 |path| {
475 let path_str = path.to_str().unwrap_or("unknown").to_string();
476 let build_time = std::fs::metadata(&path)
477 .ok()
478 .and_then(|metadata| metadata.modified().ok())
479 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
480 .and_then(|duration| {
481 let secs = duration.as_secs();
482 chrono::DateTime::from_timestamp(secs as i64, 0)
483 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
484 })
485 .unwrap_or_else(|| "unknown".to_string());
486 (path_str, build_time)
487 },
488 );
489
490 let working_dir = std::env::current_dir()
491 .ok()
492 .and_then(|p| p.to_str().map(std::string::ToString::to_string))
493 .unwrap_or_else(|| "unknown".to_string());
494
495 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
496 log::info!("Working directory: {working_dir}");
497
498 self.client
499 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
500 .await;
501
502 if !self.queue_index_update(IndexUpdate::FullRescan).await {
504 log::warn!("Failed to trigger initial workspace indexing");
505 } else {
506 log::info!("Triggered initial workspace indexing for cross-file analysis");
507 }
508
509 let markdown_patterns = [
511 "**/*.md",
512 "**/*.markdown",
513 "**/*.mdx",
514 "**/*.mkd",
515 "**/*.mkdn",
516 "**/*.mdown",
517 "**/*.mdwn",
518 "**/*.qmd",
519 "**/*.rmd",
520 ];
521 let config_patterns = [
525 "**/.rumdl.toml",
526 "**/rumdl.toml",
527 "**/pyproject.toml",
528 "**/.markdownlint.json",
529 "**/.markdownlint-cli2.yaml",
530 "**/.markdownlint-cli2.jsonc",
531 "**/.editorconfig",
532 ];
533 let watchers: Vec<_> = markdown_patterns
534 .iter()
535 .chain(config_patterns.iter())
536 .map(|pattern| FileSystemWatcher {
537 glob_pattern: GlobPattern::String((*pattern).to_string()),
538 kind: Some(WatchKind::all()),
539 })
540 .collect();
541
542 let registration = Registration {
543 id: "markdown-watcher".to_string(),
544 method: "workspace/didChangeWatchedFiles".to_string(),
545 register_options: Some(
546 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
547 ),
548 };
549
550 if self.client.register_capability(vec![registration]).await.is_err() {
551 log::debug!("Client does not support file watching capability");
552 }
553 }
554
555 async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
556 let uri = params.text_document_position.text_document.uri;
557 let position = params.text_document_position.position;
558
559 let Some(text) = self.get_document_content(&uri).await else {
561 return Ok(None);
562 };
563
564 if let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) {
566 log::debug!(
567 "Code fence completion triggered at {}:{}, current text: '{}'",
568 position.line,
569 position.character,
570 current_text
571 );
572 let items = self
573 .get_language_completions(&uri, ¤t_text, start_col, position)
574 .await;
575 if !items.is_empty() {
576 return Ok(Some(CompletionResponse::Array(items)));
577 }
578 }
579
580 if self.config.read().await.enable_link_completions {
582 let trigger = params.context.as_ref().and_then(|c| c.trigger_character.as_deref());
586 let skip_link_check = matches!(trigger, Some("." | "-")) && {
587 let line_num = position.line as usize;
588 !text.lines().nth(line_num).is_some_and(|line| line.contains("]("))
591 };
592
593 if !skip_link_check && let Some(link_info) = Self::detect_link_target_position(&text, position) {
594 if let Some((partial_anchor, anchor_start_col)) = link_info.anchor {
595 log::debug!(
596 "Anchor completion triggered at {}:{}, file: '{}', partial: '{}'",
597 position.line,
598 position.character,
599 link_info.file_path,
600 partial_anchor
601 );
602 let items = self
603 .get_anchor_completions(&uri, &link_info.file_path, &partial_anchor, anchor_start_col, position)
604 .await;
605 if !items.is_empty() {
606 return Ok(Some(CompletionResponse::Array(items)));
607 }
608 } else {
609 log::debug!(
610 "File path completion triggered at {}:{}, partial: '{}'",
611 position.line,
612 position.character,
613 link_info.file_path
614 );
615 let list = self
616 .get_file_completions(&uri, &link_info.file_path, link_info.path_start_col, position)
617 .await;
618 if !list.items.is_empty() {
619 return Ok(Some(CompletionResponse::List(list)));
620 }
621 }
622 }
623 }
624
625 Ok(None)
626 }
627
628 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
629 let mut roots = self.workspace_roots.write().await;
631
632 for removed in ¶ms.event.removed {
636 if let Ok(path) = removed.uri.to_file_path() {
637 let path = super::resolve_workspace_root(&path);
638 roots.retain(|r| r != &path);
639 log::info!("Removed workspace root: {}", path.display());
640 }
641 }
642
643 for added in ¶ms.event.added {
645 if let Ok(path) = added.uri.to_file_path()
646 && let path = super::resolve_workspace_root(&path)
647 && !roots.contains(&path)
648 {
649 log::info!("Added workspace root: {}", path.display());
650 roots.push(path);
651 }
652 }
653 drop(roots);
654
655 self.config_cache.write().await.clear();
657
658 self.reload_configuration().await;
660
661 if !self.queue_index_update(IndexUpdate::FullRescan).await {
663 log::warn!("Failed to trigger workspace rescan after folder change");
664 }
665 }
666
667 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
668 log::debug!("Configuration changed: {:?}", params.settings);
669
670 let settings_value = params.settings;
674
675 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
677 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
678 } else {
679 settings_value
680 };
681
682 let has_content_roots_key = matches!(
686 &rumdl_settings,
687 serde_json::Value::Object(obj) if obj.contains_key("linkCompletionContentRoots")
688 );
689
690 let has_symbols_key = matches!(
695 &rumdl_settings,
696 serde_json::Value::Object(obj) if obj.contains_key("enableSymbols")
697 );
698
699 let mut config_applied = false;
701 let mut warnings: Vec<String> = Vec::new();
702
703 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
707 && (rule_settings.disable.is_some()
708 || rule_settings.enable.is_some()
709 || rule_settings.line_length.is_some()
710 || (!rule_settings.rules.is_empty() && rule_settings.rules.keys().all(|k| is_valid_rule_name(k))))
711 {
712 if let Some(ref disable) = rule_settings.disable {
714 for rule in disable {
715 if !is_valid_rule_name(rule) {
716 warnings.push(format!("Unknown rule in disable list: {rule}"));
717 }
718 }
719 }
720 if let Some(ref enable) = rule_settings.enable {
721 for rule in enable {
722 if !is_valid_rule_name(rule) {
723 warnings.push(format!("Unknown rule in enable list: {rule}"));
724 }
725 }
726 }
727 for rule_name in rule_settings.rules.keys() {
729 if !is_valid_rule_name(rule_name) {
730 warnings.push(format!("Unknown rule in settings: {rule_name}"));
731 }
732 }
733
734 log::info!("Applied rule settings from configuration (Neovim style)");
735 let mut config = self.config.write().await;
736 config.settings = Some(rule_settings);
737 drop(config);
738 config_applied = true;
739 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
740 && (full_config.config_path.is_some()
741 || full_config.enable_rules.is_some()
742 || full_config.disable_rules.is_some()
743 || full_config.settings.is_some()
744 || !full_config.enable_linting
745 || full_config.enable_auto_fix
746 || !full_config.enable_link_completions
747 || !full_config.enable_link_navigation
748 || has_symbols_key
749 || has_content_roots_key)
750 {
751 if let Some(ref rules) = full_config.enable_rules {
753 for rule in rules {
754 if !is_valid_rule_name(rule) {
755 warnings.push(format!("Unknown rule in enableRules: {rule}"));
756 }
757 }
758 }
759 if let Some(ref rules) = full_config.disable_rules {
760 for rule in rules {
761 if !is_valid_rule_name(rule) {
762 warnings.push(format!("Unknown rule in disableRules: {rule}"));
763 }
764 }
765 }
766
767 {
775 let mut config = self.config.write().await;
776 if let Some(merged) = merge_lsp_config(&config, &rumdl_settings) {
777 *config = merged;
778 drop(config);
779 log::info!("Merged LSP configuration from client settings");
780 config_applied = true;
781 } else {
782 drop(config);
783 warnings.push("Could not merge LSP configuration update; keeping current settings".to_string());
784 }
785 }
786 } else if let serde_json::Value::Object(obj) = rumdl_settings {
787 let mut config = self.config.write().await;
790
791 let mut rules = std::collections::HashMap::new();
793 let mut disable = Vec::new();
794 let mut enable = Vec::new();
795 let mut line_length = None;
796
797 for (key, value) in obj {
798 match key.as_str() {
799 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
800 Ok(d) => {
801 if d.len() > MAX_RULE_LIST_SIZE {
802 warnings.push(format!(
803 "Too many rules in 'disable' ({} > {}), truncating",
804 d.len(),
805 MAX_RULE_LIST_SIZE
806 ));
807 }
808 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
809 if !is_valid_rule_name(rule) {
810 warnings.push(format!("Unknown rule in disable: {rule}"));
811 }
812 }
813 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
814 }
815 Err(_) => {
816 warnings.push(format!(
817 "Invalid 'disable' value: expected array of strings, got {value}"
818 ));
819 }
820 },
821 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
822 Ok(e) => {
823 if e.len() > MAX_RULE_LIST_SIZE {
824 warnings.push(format!(
825 "Too many rules in 'enable' ({} > {}), truncating",
826 e.len(),
827 MAX_RULE_LIST_SIZE
828 ));
829 }
830 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
831 if !is_valid_rule_name(rule) {
832 warnings.push(format!("Unknown rule in enable: {rule}"));
833 }
834 }
835 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
836 }
837 Err(_) => {
838 warnings.push(format!(
839 "Invalid 'enable' value: expected array of strings, got {value}"
840 ));
841 }
842 },
843 "lineLength" | "line_length" | "line-length" => {
844 if let Some(l) = value.as_u64() {
845 match usize::try_from(l) {
846 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
847 Ok(len) => warnings.push(format!(
848 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
849 )),
850 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
851 }
852 } else {
853 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
854 }
855 }
856 _ if key.starts_with("MD") || key.starts_with("md") => {
858 let normalized = key.to_uppercase();
859 if !is_valid_rule_name(&normalized) {
860 warnings.push(format!("Unknown rule: {key}"));
861 }
862 rules.insert(normalized, value);
863 }
864 _ => {
865 warnings.push(format!("Unknown configuration key: {key}"));
867 }
868 }
869 }
870
871 let settings = LspRuleSettings {
872 line_length,
873 disable: if disable.is_empty() { None } else { Some(disable) },
874 enable: if enable.is_empty() { None } else { Some(enable) },
875 rules,
876 };
877
878 log::info!("Applied Neovim-style rule settings (manual parse)");
879 config.settings = Some(settings);
880 drop(config);
881 config_applied = true;
882 } else {
883 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
884 }
885
886 for warning in &warnings {
888 log::warn!("{warning}");
889 }
890
891 if !warnings.is_empty() {
893 let message = if warnings.len() == 1 {
894 format!("rumdl: {}", warnings[0])
895 } else {
896 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
897 };
898 self.client.log_message(MessageType::WARNING, message).await;
899 }
900
901 if !config_applied {
902 log::debug!("No configuration changes applied");
903 }
904
905 self.config_cache.write().await.clear();
907
908 if config_applied {
916 self.load_configuration(false).await;
917
918 if !self.queue_index_update(IndexUpdate::FullRescan).await {
922 log::warn!("Failed to request workspace rescan after configuration change");
923 }
924 }
925
926 let doc_list: Vec<_> = {
931 let documents = self.documents.read().await;
932 documents
933 .iter()
934 .filter(|(_, entry)| !entry.from_disk)
935 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
936 .collect()
937 };
938
939 let tasks: Vec<_> = doc_list
943 .into_iter()
944 .map(|(uri, text)| {
945 let server = self.clone();
946 tokio::spawn(async move {
947 server.update_diagnostics(uri, text, true).await;
948 })
949 })
950 .collect();
951
952 for task in tasks {
954 let _ = task.await;
955 }
956 }
957
958 async fn shutdown(&self) -> JsonRpcResult<()> {
959 log::info!("Shutting down rumdl Language Server");
960
961 self.queue_index_update(IndexUpdate::Shutdown).await;
963
964 Ok(())
965 }
966
967 async fn did_open(&self, params: DidOpenTextDocumentParams) {
968 let uri = params.text_document.uri;
969 let text = params.text_document.text;
970 let version = params.text_document.version;
971
972 let entry = DocumentEntry {
973 content: text.clone(),
974 version: Some(version),
975 from_disk: false,
976 };
977 self.documents.write().await.insert(uri.clone(), entry);
978
979 let resolved = super::resolve_uri_spelling(&uri);
981 if resolved != uri {
982 let mut aliases = self.document_aliases.write().await;
983 let spellings = aliases.entry(resolved).or_default();
984 if !spellings.contains(&uri) {
985 spellings.push(uri.clone());
986 }
987 }
988
989 if let Some(path) = super::resolve_uri(&uri) {
991 self.queue_index_update(IndexUpdate::FileChanged {
992 path,
993 content: text.clone(),
994 })
995 .await;
996 }
997
998 self.update_diagnostics(uri, text, true).await;
999 }
1000
1001 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1002 let uri = params.text_document.uri;
1003 let version = params.text_document.version;
1004
1005 if let Some(change) = params.content_changes.into_iter().next() {
1006 let text = change.text;
1007
1008 let entry = DocumentEntry {
1009 content: text.clone(),
1010 version: Some(version),
1011 from_disk: false,
1012 };
1013 self.documents.write().await.insert(uri.clone(), entry);
1014
1015 if let Some(path) = super::resolve_uri(&uri) {
1017 self.queue_index_update(IndexUpdate::FileChanged {
1018 path,
1019 content: text.clone(),
1020 })
1021 .await;
1022 }
1023
1024 self.update_diagnostics(uri, text, false).await;
1025 }
1026 }
1027
1028 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1029 if params.reason != TextDocumentSaveReason::MANUAL {
1032 return Ok(None);
1033 }
1034
1035 let config_guard = self.config.read().await;
1036 let enable_auto_fix = config_guard.enable_auto_fix;
1037 drop(config_guard);
1038
1039 if !enable_auto_fix {
1040 return Ok(None);
1041 }
1042
1043 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1045 return Ok(None);
1046 };
1047
1048 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1050 Ok(Some(fixed_text)) => {
1051 Ok(Some(vec![TextEdit {
1053 range: Range {
1054 start: Position { line: 0, character: 0 },
1055 end: self.get_end_position(&text),
1056 },
1057 new_text: fixed_text,
1058 }]))
1059 }
1060 Ok(None) => Ok(None),
1061 Err(e) => {
1062 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1063 Ok(None)
1064 }
1065 }
1066 }
1067
1068 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1069 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1072 self.update_diagnostics(params.text_document.uri, entry.content.clone(), true)
1073 .await;
1074 }
1075 }
1076
1077 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1078 self.documents.write().await.remove(¶ms.text_document.uri);
1080 let resolved = super::resolve_uri_spelling(¶ms.text_document.uri);
1083 if resolved != params.text_document.uri {
1084 let mut aliases = self.document_aliases.write().await;
1085 if let Some(spellings) = aliases.get_mut(&resolved) {
1086 spellings.retain(|u| u != ¶ms.text_document.uri);
1087 if spellings.is_empty() {
1088 aliases.remove(&resolved);
1089 }
1090 }
1091 }
1092
1093 self.client
1096 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1097 .await;
1098 }
1099
1100 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1101 const CONFIG_FILES: &[&str] = &[
1103 ".rumdl.toml",
1104 "rumdl.toml",
1105 "pyproject.toml",
1106 ".markdownlint.json",
1107 ".markdownlint-cli2.jsonc",
1108 ".markdownlint-cli2.yaml",
1109 ".markdownlint-cli2.yml",
1110 ];
1111
1112 let mut config_changed = false;
1113 let reads_editorconfig = self.reads_editorconfig().await;
1116
1117 for change in ¶ms.changes {
1118 if let Some(path) = super::resolve_uri(&change.uri) {
1122 let file_name = path.file_name().and_then(|f| f.to_str());
1123
1124 if let Some(name) = file_name
1126 && (CONFIG_FILES.contains(&name) || (reads_editorconfig && name == ".editorconfig"))
1127 && !config_changed
1128 {
1129 log::info!("Config file changed: {}, invalidating config cache", path.display());
1130
1131 let mut cache = self.config_cache.write().await;
1135 cache.clear();
1136
1137 drop(cache);
1139 self.reload_configuration().await;
1140 config_changed = true;
1141 }
1142
1143 if let Some(ext) = path.extension()
1145 && is_markdown_extension(ext)
1146 {
1147 match change.typ {
1148 FileChangeType::CREATED | FileChangeType::CHANGED => {
1149 if let Some(content) = self
1159 .get_open_document_content(&super::resolve_uri_spelling(&change.uri))
1160 .await
1161 {
1162 self.queue_index_update(IndexUpdate::FileChanged {
1163 path: path.clone(),
1164 content,
1165 })
1166 .await;
1167 continue;
1168 }
1169 let roots = self.workspace_roots.read().await.clone();
1173 let (options, excludes) = {
1174 let config = self.rumdl_config.read().await;
1175 (
1176 crate::lsp::index_worker::index_walk_options(&config),
1177 ExcludeMatchers::new(&config.global.exclude),
1178 )
1179 };
1180 if crate::lsp::index_worker::path_is_ignored_for_index(&roots, &path, &options, &excludes) {
1181 self.queue_index_update(IndexUpdate::FileRemoved { path: path.clone() })
1186 .await;
1187 continue;
1188 }
1189 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1191 self.queue_index_update(IndexUpdate::FileChanged {
1192 path: path.clone(),
1193 content,
1194 })
1195 .await;
1196 }
1197 }
1198 FileChangeType::DELETED => {
1199 self.queue_index_update(IndexUpdate::FileRemoved { path: path.clone() })
1200 .await;
1201 }
1202 _ => {}
1203 }
1204 }
1205 }
1206 }
1207
1208 if config_changed {
1210 if !self.queue_index_update(IndexUpdate::FullRescan).await {
1214 log::warn!("Failed to request workspace rescan after config change");
1215 }
1216
1217 let docs_to_update: Vec<(Url, String)> = {
1218 let docs = self.documents.read().await;
1219 docs.iter()
1220 .filter(|(_, entry)| !entry.from_disk)
1221 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1222 .collect()
1223 };
1224
1225 for (uri, text) in docs_to_update {
1226 self.update_diagnostics(uri, text, true).await;
1227 }
1228 }
1229 }
1230
1231 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1232 let uri = params.text_document.uri;
1233 let range = params.range;
1234 let requested_kinds = params.context.only;
1235
1236 if let Some(text) = self.get_document_content(&uri).await {
1237 match self.get_code_actions(&uri, &text, range).await {
1238 Ok(actions) => {
1239 let filtered_actions = if let Some(ref kinds) = requested_kinds
1243 && !kinds.is_empty()
1244 {
1245 actions
1246 .into_iter()
1247 .filter(|action| {
1248 action.kind.as_ref().is_some_and(|action_kind| {
1249 let action_kind_str = action_kind.as_str();
1250 kinds.iter().any(|requested| {
1251 let requested_str = requested.as_str();
1252 action_kind_str.starts_with(requested_str)
1255 })
1256 })
1257 })
1258 .collect()
1259 } else {
1260 actions
1261 };
1262
1263 let response: Vec<CodeActionOrCommand> = filtered_actions
1264 .into_iter()
1265 .map(CodeActionOrCommand::CodeAction)
1266 .collect();
1267 Ok(Some(response))
1268 }
1269 Err(e) => {
1270 log::error!("Failed to get code actions: {e}");
1271 Ok(None)
1272 }
1273 }
1274 } else {
1275 Ok(None)
1276 }
1277 }
1278
1279 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1280 log::debug!(
1285 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
1286 params.range
1287 );
1288
1289 let formatting_params = DocumentFormattingParams {
1290 text_document: params.text_document,
1291 options: params.options,
1292 work_done_progress_params: params.work_done_progress_params,
1293 };
1294
1295 self.formatting(formatting_params).await
1296 }
1297
1298 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1299 let uri = params.text_document.uri;
1300 let options = params.options;
1301
1302 log::debug!("Formatting request for: {uri}");
1303 log::debug!(
1304 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
1305 options.insert_final_newline,
1306 options.trim_final_newlines,
1307 options.trim_trailing_whitespace
1308 );
1309
1310 if let Some(text) = self.get_document_content(&uri).await {
1311 let mut result = match self.apply_all_fixes(&uri, &text).await {
1320 Ok(Some(fixed)) => fixed,
1321 Ok(None) => text.clone(),
1322 Err(e) => {
1323 log::error!("Failed to apply fixes during formatting: {e}");
1324 text.clone()
1325 }
1326 };
1327
1328 result = Self::apply_formatting_options(result, &options);
1331
1332 if result != text {
1334 log::debug!("Returning formatting edits");
1335 let end_position = self.get_end_position(&text);
1336 let edit = TextEdit {
1337 range: Range {
1338 start: Position { line: 0, character: 0 },
1339 end: end_position,
1340 },
1341 new_text: result,
1342 };
1343 return Ok(Some(vec![edit]));
1344 }
1345
1346 Ok(Some(Vec::new()))
1347 } else {
1348 log::warn!("Document not found: {uri}");
1349 Ok(None)
1350 }
1351 }
1352
1353 async fn goto_definition(&self, params: GotoDefinitionParams) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
1354 if !self.config.read().await.enable_link_navigation {
1355 return Ok(None);
1356 }
1357 let uri = params.text_document_position_params.text_document.uri;
1358 let position = params.text_document_position_params.position;
1359
1360 log::debug!("Go-to-definition at {uri} {}:{}", position.line, position.character);
1361
1362 Ok(self.handle_goto_definition(&uri, position).await)
1363 }
1364
1365 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
1366 if !self.config.read().await.enable_link_navigation {
1367 return Ok(None);
1368 }
1369 let uri = params.text_document_position.text_document.uri;
1370 let position = params.text_document_position.position;
1371
1372 log::debug!("Find references at {uri} {}:{}", position.line, position.character);
1373
1374 Ok(self.handle_references(&uri, position).await)
1375 }
1376
1377 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1378 if !self.config.read().await.enable_link_navigation {
1379 return Ok(None);
1380 }
1381 let uri = params.text_document_position_params.text_document.uri;
1382 let position = params.text_document_position_params.position;
1383
1384 log::debug!("Hover at {uri} {}:{}", position.line, position.character);
1385
1386 Ok(self.handle_hover(&uri, position).await)
1387 }
1388
1389 async fn prepare_rename(&self, params: TextDocumentPositionParams) -> JsonRpcResult<Option<PrepareRenameResponse>> {
1390 if !self.config.read().await.enable_link_navigation {
1391 return Ok(None);
1392 }
1393 let uri = params.text_document.uri;
1394 let position = params.position;
1395
1396 log::debug!("Prepare rename at {uri} {}:{}", position.line, position.character);
1397
1398 Ok(self.handle_prepare_rename(&uri, position).await)
1399 }
1400
1401 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
1402 if !self.config.read().await.enable_link_navigation {
1403 return Ok(None);
1404 }
1405 let uri = params.text_document_position.text_document.uri;
1406 let position = params.text_document_position.position;
1407 let new_name = params.new_name;
1408
1409 log::debug!("Rename at {uri} {}:{} → {new_name}", position.line, position.character);
1410
1411 Ok(self.handle_rename(&uri, position, &new_name).await)
1412 }
1413
1414 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
1415 let uri = params.text_document.uri;
1416
1417 if let Some(text) = self.get_open_document_content(&uri).await {
1418 match self.lint_document(&uri, &text, true).await {
1419 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1420 RelatedFullDocumentDiagnosticReport {
1421 related_documents: None,
1422 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1423 result_id: None,
1424 items: diagnostics,
1425 },
1426 },
1427 ))),
1428 Err(e) => {
1429 log::error!("Failed to get diagnostics: {e}");
1430 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1431 RelatedFullDocumentDiagnosticReport {
1432 related_documents: None,
1433 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1434 result_id: None,
1435 items: Vec::new(),
1436 },
1437 },
1438 )))
1439 }
1440 }
1441 } else {
1442 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
1443 RelatedFullDocumentDiagnosticReport {
1444 related_documents: None,
1445 full_document_diagnostic_report: FullDocumentDiagnosticReport {
1446 result_id: None,
1447 items: Vec::new(),
1448 },
1449 },
1450 )))
1451 }
1452 }
1453
1454 async fn document_symbol(&self, params: DocumentSymbolParams) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
1455 if !self.config.read().await.enable_symbols {
1456 return Ok(None);
1457 }
1458
1459 let uri = params.text_document.uri;
1460 let Some(text) = self.get_document_content(&uri).await else {
1461 return Ok(None);
1462 };
1463
1464 let flavor = self.resolve_flavor_for_uri(&uri).await;
1465 let ctx = crate::lint_context::LintContext::new(&text, flavor, None);
1466
1467 if *self.client_supports_hierarchical_symbols.read().await {
1468 let symbols = super::symbols::document_symbols(&ctx);
1469 Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Nested(symbols)))
1470 } else {
1471 let symbols = super::symbols::document_symbols_flat(&ctx, &uri);
1472 Ok((!symbols.is_empty()).then_some(DocumentSymbolResponse::Flat(symbols)))
1473 }
1474 }
1475
1476 async fn symbol(&self, params: WorkspaceSymbolParams) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
1477 if !self.config.read().await.enable_symbols {
1478 return Ok(None);
1479 }
1480
1481 let query = params.query.to_lowercase();
1482 let index = self.workspace_index.read().await;
1483 let symbols = super::symbols::workspace_symbols(&index, &query);
1484 Ok(if symbols.is_empty() { None } else { Some(symbols) })
1485 }
1486}
1487
1488#[cfg(test)]
1489#[path = "tests.rs"]
1490mod tests;