1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use anyhow::Result;
11use futures::future::join_all;
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::jsonrpc::Result as JsonRpcResult;
14use tower_lsp::lsp_types::*;
15use tower_lsp::{Client, LanguageServer};
16
17use crate::config::{Config, is_valid_rule_name};
18use crate::linguist_data::{CANONICAL_TO_ALIASES, default_alias};
19use crate::lint;
20use crate::lsp::index_worker::IndexWorker;
21use crate::lsp::types::{
22 ConfigurationPreference, IndexState, IndexUpdate, LspRuleSettings, RumdlLspConfig, warning_to_code_actions,
23 warning_to_diagnostic,
24};
25use crate::rule::{FixCapability, Rule};
26use crate::rule_config_serde::load_rule_config;
27use crate::rules;
28use crate::rules::md040_fenced_code_language::md040_config::MD040Config;
29use crate::workspace_index::WorkspaceIndex;
30
31const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
33
34const MAX_RULE_LIST_SIZE: usize = 100;
36
37const MAX_LINE_LENGTH: usize = 10_000;
39
40#[inline]
42fn is_markdown_extension(ext: &str) -> bool {
43 MARKDOWN_EXTENSIONS.contains(&ext.to_lowercase().as_str())
44}
45
46#[derive(Clone, Debug, PartialEq)]
48struct DocumentEntry {
49 content: String,
51 version: Option<i32>,
53 from_disk: bool,
55}
56
57#[derive(Clone, Debug)]
59pub(crate) struct ConfigCacheEntry {
60 pub(crate) config: Config,
62 pub(crate) config_file: Option<PathBuf>,
64 pub(crate) from_global_fallback: bool,
66}
67
68#[derive(Clone)]
78pub struct RumdlLanguageServer {
79 client: Client,
80 config: Arc<RwLock<RumdlLspConfig>>,
82 #[cfg_attr(test, allow(dead_code))]
84 pub(crate) rumdl_config: Arc<RwLock<Config>>,
85 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
87 #[cfg_attr(test, allow(dead_code))]
89 pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
90 #[cfg_attr(test, allow(dead_code))]
93 pub(crate) config_cache: Arc<RwLock<HashMap<PathBuf, ConfigCacheEntry>>>,
94 workspace_index: Arc<RwLock<WorkspaceIndex>>,
96 index_state: Arc<RwLock<IndexState>>,
98 update_tx: mpsc::Sender<IndexUpdate>,
100 client_supports_pull_diagnostics: Arc<RwLock<bool>>,
103}
104
105impl RumdlLanguageServer {
106 pub fn new(client: Client, cli_config_path: Option<&str>) -> Self {
107 let mut initial_config = RumdlLspConfig::default();
109 if let Some(path) = cli_config_path {
110 initial_config.config_path = Some(path.to_string());
111 }
112
113 let workspace_index = Arc::new(RwLock::new(WorkspaceIndex::new()));
115 let index_state = Arc::new(RwLock::new(IndexState::default()));
116 let workspace_roots = Arc::new(RwLock::new(Vec::new()));
117
118 let (update_tx, update_rx) = mpsc::channel::<IndexUpdate>(100);
120 let (relint_tx, _relint_rx) = mpsc::channel::<PathBuf>(100);
121
122 let worker = IndexWorker::new(
124 update_rx,
125 workspace_index.clone(),
126 index_state.clone(),
127 client.clone(),
128 workspace_roots.clone(),
129 relint_tx,
130 );
131 tokio::spawn(worker.run());
132
133 Self {
134 client,
135 config: Arc::new(RwLock::new(initial_config)),
136 rumdl_config: Arc::new(RwLock::new(Config::default())),
137 documents: Arc::new(RwLock::new(HashMap::new())),
138 workspace_roots,
139 config_cache: Arc::new(RwLock::new(HashMap::new())),
140 workspace_index,
141 index_state,
142 update_tx,
143 client_supports_pull_diagnostics: Arc::new(RwLock::new(false)),
144 }
145 }
146
147 async fn get_document_content(&self, uri: &Url) -> Option<String> {
153 {
155 let docs = self.documents.read().await;
156 if let Some(entry) = docs.get(uri) {
157 return Some(entry.content.clone());
158 }
159 }
160
161 if let Ok(path) = uri.to_file_path() {
163 if let Ok(content) = tokio::fs::read_to_string(&path).await {
164 let entry = DocumentEntry {
166 content: content.clone(),
167 version: None,
168 from_disk: true,
169 };
170
171 let mut docs = self.documents.write().await;
172 docs.insert(uri.clone(), entry);
173
174 log::debug!("Loaded document from disk and cached: {uri}");
175 return Some(content);
176 } else {
177 log::debug!("Failed to read file from disk: {uri}");
178 }
179 }
180
181 None
182 }
183
184 async fn get_open_document_content(&self, uri: &Url) -> Option<String> {
190 let docs = self.documents.read().await;
191 docs.get(uri)
192 .and_then(|entry| (!entry.from_disk).then(|| entry.content.clone()))
193 }
194
195 fn apply_lsp_config_overrides(
197 &self,
198 mut filtered_rules: Vec<Box<dyn Rule>>,
199 lsp_config: &RumdlLspConfig,
200 ) -> Vec<Box<dyn Rule>> {
201 let mut enable_rules: Vec<String> = Vec::new();
203 if let Some(enable) = &lsp_config.enable_rules {
204 enable_rules.extend(enable.iter().cloned());
205 }
206 if let Some(settings) = &lsp_config.settings
207 && let Some(enable) = &settings.enable
208 {
209 enable_rules.extend(enable.iter().cloned());
210 }
211
212 if !enable_rules.is_empty() {
214 let enable_set: std::collections::HashSet<String> = enable_rules.into_iter().collect();
215 filtered_rules.retain(|rule| enable_set.contains(rule.name()));
216 }
217
218 let mut disable_rules: Vec<String> = Vec::new();
220 if let Some(disable) = &lsp_config.disable_rules {
221 disable_rules.extend(disable.iter().cloned());
222 }
223 if let Some(settings) = &lsp_config.settings
224 && let Some(disable) = &settings.disable
225 {
226 disable_rules.extend(disable.iter().cloned());
227 }
228
229 if !disable_rules.is_empty() {
231 let disable_set: std::collections::HashSet<String> = disable_rules.into_iter().collect();
232 filtered_rules.retain(|rule| !disable_set.contains(rule.name()));
233 }
234
235 filtered_rules
236 }
237
238 fn merge_lsp_settings(&self, mut file_config: Config, lsp_config: &RumdlLspConfig) -> Config {
244 let Some(settings) = &lsp_config.settings else {
245 return file_config;
246 };
247
248 match lsp_config.configuration_preference {
249 ConfigurationPreference::EditorFirst => {
250 self.apply_lsp_settings_to_config(&mut file_config, settings);
252 }
253 ConfigurationPreference::FilesystemFirst => {
254 self.apply_lsp_settings_if_absent(&mut file_config, settings);
256 }
257 ConfigurationPreference::EditorOnly => {
258 let mut default_config = Config::default();
260 self.apply_lsp_settings_to_config(&mut default_config, settings);
261 return default_config;
262 }
263 }
264
265 file_config
266 }
267
268 fn apply_lsp_settings_to_config(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
270 if let Some(line_length) = settings.line_length {
272 config.global.line_length = crate::types::LineLength::new(line_length);
273 }
274
275 if let Some(disable) = &settings.disable {
277 config.global.disable.extend(disable.iter().cloned());
278 }
279
280 if let Some(enable) = &settings.enable {
282 config.global.enable.extend(enable.iter().cloned());
283 }
284
285 for (rule_name, rule_config) in &settings.rules {
287 self.apply_rule_config(config, rule_name, rule_config);
288 }
289 }
290
291 fn apply_lsp_settings_if_absent(&self, config: &mut Config, settings: &crate::lsp::types::LspRuleSettings) {
293 if config.global.line_length.get() == 80
296 && let Some(line_length) = settings.line_length
297 {
298 config.global.line_length = crate::types::LineLength::new(line_length);
299 }
300
301 if let Some(disable) = &settings.disable {
303 config.global.disable.extend(disable.iter().cloned());
304 }
305
306 if let Some(enable) = &settings.enable {
307 config.global.enable.extend(enable.iter().cloned());
308 }
309
310 for (rule_name, rule_config) in &settings.rules {
312 self.apply_rule_config_if_absent(config, rule_name, rule_config);
313 }
314 }
315
316 fn apply_rule_config(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
321 let rule_key = rule_name.to_uppercase();
322
323 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
325
326 if let Some(obj) = rule_config.as_object() {
328 for (key, value) in obj {
329 let config_key = Self::camel_to_snake(key);
331
332 if config_key == "severity" {
334 if let Some(severity_str) = value.as_str() {
335 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
336 severity_str.to_string(),
337 )) {
338 Ok(severity) => {
339 rule_entry.severity = Some(severity);
340 }
341 Err(_) => {
342 log::warn!(
343 "Invalid severity '{severity_str}' for rule {rule_key}. \
344 Valid values: error, warning, info"
345 );
346 }
347 }
348 }
349 continue;
350 }
351
352 if let Some(toml_value) = Self::json_to_toml(value) {
354 rule_entry.values.insert(config_key, toml_value);
355 }
356 }
357 }
358 }
359
360 fn apply_rule_config_if_absent(&self, config: &mut Config, rule_name: &str, rule_config: &serde_json::Value) {
368 let rule_key = rule_name.to_uppercase();
369
370 let existing_rule = config.rules.get(&rule_key);
372 let has_existing_values = existing_rule.map(|r| !r.values.is_empty()).unwrap_or(false);
373 let has_existing_severity = existing_rule.and_then(|r| r.severity).is_some();
374
375 if let Some(obj) = rule_config.as_object() {
377 let rule_entry = config.rules.entry(rule_key.clone()).or_default();
378
379 for (key, value) in obj {
380 let config_key = Self::camel_to_snake(key);
381
382 if config_key == "severity" {
384 if !has_existing_severity && let Some(severity_str) = value.as_str() {
385 match serde_json::from_value::<crate::rule::Severity>(serde_json::Value::String(
386 severity_str.to_string(),
387 )) {
388 Ok(severity) => {
389 rule_entry.severity = Some(severity);
390 }
391 Err(_) => {
392 log::warn!(
393 "Invalid severity '{severity_str}' for rule {rule_key}. \
394 Valid values: error, warning, info"
395 );
396 }
397 }
398 }
399 continue;
400 }
401
402 if !has_existing_values && let Some(toml_value) = Self::json_to_toml(value) {
404 rule_entry.values.insert(config_key, toml_value);
405 }
406 }
407 }
408 }
409
410 fn camel_to_snake(s: &str) -> String {
412 let mut result = String::new();
413 for (i, c) in s.chars().enumerate() {
414 if c.is_uppercase() && i > 0 {
415 result.push('_');
416 }
417 result.push(c.to_lowercase().next().unwrap_or(c));
418 }
419 result
420 }
421
422 fn json_to_toml(json: &serde_json::Value) -> Option<toml::Value> {
424 match json {
425 serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
426 serde_json::Value::Number(n) => {
427 if let Some(i) = n.as_i64() {
428 Some(toml::Value::Integer(i))
429 } else {
430 n.as_f64().map(toml::Value::Float)
431 }
432 }
433 serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
434 serde_json::Value::Array(arr) => {
435 let toml_arr: Vec<toml::Value> = arr.iter().filter_map(Self::json_to_toml).collect();
436 Some(toml::Value::Array(toml_arr))
437 }
438 serde_json::Value::Object(obj) => {
439 let mut table = toml::map::Map::new();
440 for (k, v) in obj {
441 if let Some(toml_v) = Self::json_to_toml(v) {
442 table.insert(Self::camel_to_snake(k), toml_v);
443 }
444 }
445 Some(toml::Value::Table(table))
446 }
447 serde_json::Value::Null => None,
448 }
449 }
450
451 async fn should_exclude_uri(&self, uri: &Url) -> bool {
453 let file_path = match uri.to_file_path() {
455 Ok(path) => path,
456 Err(_) => return false, };
458
459 let rumdl_config = self.resolve_config_for_file(&file_path).await;
461 let exclude_patterns = &rumdl_config.global.exclude;
462
463 if exclude_patterns.is_empty() {
465 return false;
466 }
467
468 let path_to_check = if file_path.is_absolute() {
471 if let Ok(cwd) = std::env::current_dir() {
473 if let (Ok(canonical_cwd), Ok(canonical_path)) = (cwd.canonicalize(), file_path.canonicalize()) {
475 if let Ok(relative) = canonical_path.strip_prefix(&canonical_cwd) {
476 relative.to_string_lossy().to_string()
477 } else {
478 file_path.to_string_lossy().to_string()
480 }
481 } else {
482 file_path.to_string_lossy().to_string()
484 }
485 } else {
486 file_path.to_string_lossy().to_string()
487 }
488 } else {
489 file_path.to_string_lossy().to_string()
491 };
492
493 for pattern in exclude_patterns {
495 if let Ok(glob) = globset::Glob::new(pattern) {
496 let matcher = glob.compile_matcher();
497 if matcher.is_match(&path_to_check) {
498 log::debug!("Excluding file from LSP linting: {path_to_check}");
499 return true;
500 }
501 }
502 }
503
504 false
505 }
506
507 pub(crate) async fn lint_document(&self, uri: &Url, text: &str) -> Result<Vec<Diagnostic>> {
509 let config_guard = self.config.read().await;
510
511 if !config_guard.enable_linting {
513 return Ok(Vec::new());
514 }
515
516 let lsp_config = config_guard.clone();
517 drop(config_guard); if self.should_exclude_uri(uri).await {
521 return Ok(Vec::new());
522 }
523
524 let file_path = uri.to_file_path().ok();
526 let file_config = if let Some(ref path) = file_path {
527 self.resolve_config_for_file(path).await
528 } else {
529 (*self.rumdl_config.read().await).clone()
531 };
532
533 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
535
536 let all_rules = rules::all_rules(&rumdl_config);
537 let flavor = if let Some(ref path) = file_path {
538 rumdl_config.get_flavor_for_file(path)
539 } else {
540 rumdl_config.markdown_flavor()
541 };
542
543 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
545
546 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
548
549 let mut all_warnings = match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
551 Ok(warnings) => warnings,
552 Err(e) => {
553 log::error!("Failed to lint document {uri}: {e}");
554 return Ok(Vec::new());
555 }
556 };
557
558 if let Some(ref path) = file_path {
560 let index_state = self.index_state.read().await.clone();
561 if matches!(index_state, IndexState::Ready) {
562 let workspace_index = self.workspace_index.read().await;
563 if let Some(file_index) = workspace_index.get_file(path) {
564 match crate::run_cross_file_checks(
565 path,
566 file_index,
567 &filtered_rules,
568 &workspace_index,
569 Some(&rumdl_config),
570 ) {
571 Ok(cross_file_warnings) => {
572 all_warnings.extend(cross_file_warnings);
573 }
574 Err(e) => {
575 log::warn!("Failed to run cross-file checks for {uri}: {e}");
576 }
577 }
578 }
579 }
580 }
581
582 let diagnostics = all_warnings.iter().map(warning_to_diagnostic).collect();
583 Ok(diagnostics)
584 }
585
586 async fn update_diagnostics(&self, uri: Url, text: String) {
592 if *self.client_supports_pull_diagnostics.read().await {
594 log::debug!("Skipping push diagnostics for {uri} - client supports pull model");
595 return;
596 }
597
598 let version = {
600 let docs = self.documents.read().await;
601 docs.get(&uri).and_then(|entry| entry.version)
602 };
603
604 match self.lint_document(&uri, &text).await {
605 Ok(diagnostics) => {
606 self.client.publish_diagnostics(uri, diagnostics, version).await;
607 }
608 Err(e) => {
609 log::error!("Failed to update diagnostics: {e}");
610 }
611 }
612 }
613
614 async fn apply_all_fixes(&self, uri: &Url, text: &str) -> Result<Option<String>> {
616 if self.should_exclude_uri(uri).await {
618 return Ok(None);
619 }
620
621 let config_guard = self.config.read().await;
622 let lsp_config = config_guard.clone();
623 drop(config_guard);
624
625 let file_path = uri.to_file_path().ok();
627 let file_config = if let Some(ref path) = file_path {
628 self.resolve_config_for_file(path).await
629 } else {
630 (*self.rumdl_config.read().await).clone()
632 };
633
634 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
636
637 let all_rules = rules::all_rules(&rumdl_config);
638 let flavor = if let Some(ref path) = file_path {
639 rumdl_config.get_flavor_for_file(path)
640 } else {
641 rumdl_config.markdown_flavor()
642 };
643
644 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
646
647 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
649
650 let mut rules_with_warnings = std::collections::HashSet::new();
653 let mut fixed_text = text.to_string();
654
655 match lint(&fixed_text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
656 Ok(warnings) => {
657 for warning in warnings {
658 if let Some(rule_name) = &warning.rule_name {
659 rules_with_warnings.insert(rule_name.clone());
660 }
661 }
662 }
663 Err(e) => {
664 log::warn!("Failed to lint document for auto-fix: {e}");
665 return Ok(None);
666 }
667 }
668
669 if rules_with_warnings.is_empty() {
671 return Ok(None);
672 }
673
674 let mut any_changes = false;
676
677 for rule in &filtered_rules {
678 if !rules_with_warnings.contains(rule.name()) {
680 continue;
681 }
682
683 let ctx = crate::lint_context::LintContext::new(&fixed_text, flavor, None);
684 match rule.fix(&ctx) {
685 Ok(new_text) => {
686 if new_text != fixed_text {
687 fixed_text = new_text;
688 any_changes = true;
689 }
690 }
691 Err(e) => {
692 let msg = e.to_string();
694 if !msg.contains("does not support automatic fixing") {
695 log::warn!("Failed to apply fix for rule {}: {}", rule.name(), e);
696 }
697 }
698 }
699 }
700
701 if any_changes { Ok(Some(fixed_text)) } else { Ok(None) }
702 }
703
704 fn get_end_position(&self, text: &str) -> Position {
706 let mut line = 0u32;
707 let mut character = 0u32;
708
709 for ch in text.chars() {
710 if ch == '\n' {
711 line += 1;
712 character = 0;
713 } else {
714 character += 1;
715 }
716 }
717
718 Position { line, character }
719 }
720
721 fn apply_formatting_options(content: String, options: &FormattingOptions) -> String {
732 if content.is_empty() {
735 return content;
736 }
737
738 let mut result = content.clone();
739 let original_ended_with_newline = content.ends_with('\n');
740
741 if options.trim_trailing_whitespace.unwrap_or(false) {
743 result = result
744 .lines()
745 .map(|line| line.trim_end())
746 .collect::<Vec<_>>()
747 .join("\n");
748 if original_ended_with_newline && !result.ends_with('\n') {
750 result.push('\n');
751 }
752 }
753
754 if options.trim_final_newlines.unwrap_or(false) {
758 while result.ends_with('\n') {
760 result.pop();
761 }
762 }
764
765 if options.insert_final_newline.unwrap_or(false) && !result.ends_with('\n') {
767 result.push('\n');
768 }
769
770 result
771 }
772
773 async fn get_code_actions(&self, uri: &Url, text: &str, range: Range) -> Result<Vec<CodeAction>> {
775 let config_guard = self.config.read().await;
776 let lsp_config = config_guard.clone();
777 drop(config_guard);
778
779 let file_path = uri.to_file_path().ok();
781 let file_config = if let Some(ref path) = file_path {
782 self.resolve_config_for_file(path).await
783 } else {
784 (*self.rumdl_config.read().await).clone()
786 };
787
788 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
790
791 let all_rules = rules::all_rules(&rumdl_config);
792 let flavor = if let Some(ref path) = file_path {
793 rumdl_config.get_flavor_for_file(path)
794 } else {
795 rumdl_config.markdown_flavor()
796 };
797
798 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
800
801 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
803
804 match crate::lint(text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
805 Ok(warnings) => {
806 let mut actions = Vec::new();
807 let mut fixable_count = 0;
808
809 for warning in &warnings {
810 let warning_line = (warning.line.saturating_sub(1)) as u32;
812 if warning_line >= range.start.line && warning_line <= range.end.line {
813 let mut warning_actions = warning_to_code_actions(warning, uri, text);
815 actions.append(&mut warning_actions);
816
817 if warning.fix.is_some() {
818 fixable_count += 1;
819 }
820 }
821 }
822
823 if fixable_count > 1 {
825 let fixable_warnings: Vec<_> = warnings
828 .iter()
829 .filter(|w| {
830 if let Some(rule_name) = &w.rule_name {
831 filtered_rules
832 .iter()
833 .find(|r| r.name() == rule_name)
834 .map(|r| r.fix_capability() != FixCapability::Unfixable)
835 .unwrap_or(false)
836 } else {
837 false
838 }
839 })
840 .cloned()
841 .collect();
842
843 let total_fixable = fixable_warnings.len();
845
846 if let Ok(fixed_content) = crate::utils::fix_utils::apply_warning_fixes(text, &fixable_warnings)
847 && fixed_content != text
848 {
849 let mut line = 0u32;
851 let mut character = 0u32;
852 for ch in text.chars() {
853 if ch == '\n' {
854 line += 1;
855 character = 0;
856 } else {
857 character += 1;
858 }
859 }
860
861 let fix_all_action = CodeAction {
862 title: format!("Fix all rumdl issues ({total_fixable} fixable)"),
863 kind: Some(CodeActionKind::new("source.fixAll.rumdl")),
864 diagnostics: Some(Vec::new()),
865 edit: Some(WorkspaceEdit {
866 changes: Some(
867 [(
868 uri.clone(),
869 vec![TextEdit {
870 range: Range {
871 start: Position { line: 0, character: 0 },
872 end: Position { line, character },
873 },
874 new_text: fixed_content,
875 }],
876 )]
877 .into_iter()
878 .collect(),
879 ),
880 ..Default::default()
881 }),
882 command: None,
883 is_preferred: Some(true),
884 disabled: None,
885 data: None,
886 };
887
888 actions.insert(0, fix_all_action);
890 }
891 }
892
893 Ok(actions)
894 }
895 Err(e) => {
896 log::error!("Failed to get code actions: {e}");
897 Ok(Vec::new())
898 }
899 }
900 }
901
902 async fn load_configuration(&self, notify_client: bool) {
904 let config_guard = self.config.read().await;
905 let explicit_config_path = config_guard.config_path.clone();
906 drop(config_guard);
907
908 match Self::load_config_for_lsp(explicit_config_path.as_deref()) {
910 Ok(sourced_config) => {
911 let loaded_files = sourced_config.loaded_files.clone();
912 *self.rumdl_config.write().await = sourced_config.into_validated_unchecked().into();
914
915 if !loaded_files.is_empty() {
916 let message = format!("Loaded rumdl config from: {}", loaded_files.join(", "));
917 log::info!("{message}");
918 if notify_client {
919 self.client.log_message(MessageType::INFO, &message).await;
920 }
921 } else {
922 log::info!("Using default rumdl configuration (no config files found)");
923 }
924 }
925 Err(e) => {
926 let message = format!("Failed to load rumdl config: {e}");
927 log::warn!("{message}");
928 if notify_client {
929 self.client.log_message(MessageType::WARNING, &message).await;
930 }
931 *self.rumdl_config.write().await = crate::config::Config::default();
933 }
934 }
935 }
936
937 async fn reload_configuration(&self) {
939 self.load_configuration(true).await;
940 }
941
942 fn load_config_for_lsp(
944 config_path: Option<&str>,
945 ) -> Result<crate::config::SourcedConfig, crate::config::ConfigError> {
946 crate::config::SourcedConfig::load_with_discovery(config_path, None, false)
948 }
949
950 pub(crate) async fn resolve_config_for_file(&self, file_path: &std::path::Path) -> Config {
957 let search_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
959
960 {
962 let cache = self.config_cache.read().await;
963 if let Some(entry) = cache.get(&search_dir) {
964 let source_owned: String; let source: &str = if entry.from_global_fallback {
966 "global/user fallback"
967 } else if let Some(path) = &entry.config_file {
968 source_owned = path.to_string_lossy().to_string();
969 &source_owned
970 } else {
971 "<unknown>"
972 };
973 log::debug!(
974 "Config cache hit for directory: {} (loaded from: {})",
975 search_dir.display(),
976 source
977 );
978 return entry.config.clone();
979 }
980 }
981
982 log::debug!(
984 "Config cache miss for directory: {}, searching for config...",
985 search_dir.display()
986 );
987
988 let workspace_root = {
990 let workspace_roots = self.workspace_roots.read().await;
991 workspace_roots
992 .iter()
993 .find(|root| search_dir.starts_with(root))
994 .map(|p| p.to_path_buf())
995 };
996
997 let mut current_dir = search_dir.clone();
999 let mut found_config: Option<(Config, Option<PathBuf>)> = None;
1000
1001 loop {
1002 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1004
1005 for config_file_name in CONFIG_FILES {
1006 let config_path = current_dir.join(config_file_name);
1007 if config_path.exists() {
1008 if *config_file_name == "pyproject.toml" {
1010 if let Ok(content) = std::fs::read_to_string(&config_path) {
1011 if content.contains("[tool.rumdl]") || content.contains("tool.rumdl") {
1012 log::debug!("Found config file: {} (with [tool.rumdl])", config_path.display());
1013 } else {
1014 log::debug!("Found pyproject.toml but no [tool.rumdl] section, skipping");
1015 continue;
1016 }
1017 } else {
1018 log::warn!("Failed to read pyproject.toml: {}", config_path.display());
1019 continue;
1020 }
1021 } else {
1022 log::debug!("Found config file: {}", config_path.display());
1023 }
1024
1025 if let Some(config_path_str) = config_path.to_str() {
1027 if let Ok(sourced) = Self::load_config_for_lsp(Some(config_path_str)) {
1028 found_config = Some((sourced.into_validated_unchecked().into(), Some(config_path)));
1029 break;
1030 }
1031 } else {
1032 log::warn!("Skipping config file with non-UTF-8 path: {}", config_path.display());
1033 }
1034 }
1035 }
1036
1037 if found_config.is_some() {
1038 break;
1039 }
1040
1041 if let Some(ref root) = workspace_root
1043 && ¤t_dir == root
1044 {
1045 log::debug!("Hit workspace root without finding config: {}", root.display());
1046 break;
1047 }
1048
1049 if let Some(parent) = current_dir.parent() {
1051 current_dir = parent.to_path_buf();
1052 } else {
1053 break;
1055 }
1056 }
1057
1058 let (config, config_file) = if let Some((cfg, path)) = found_config {
1060 (cfg, path)
1061 } else {
1062 log::debug!("No project config found; using global/user fallback config");
1063 let fallback = self.rumdl_config.read().await.clone();
1064 (fallback, None)
1065 };
1066
1067 let from_global = config_file.is_none();
1069 let entry = ConfigCacheEntry {
1070 config: config.clone(),
1071 config_file,
1072 from_global_fallback: from_global,
1073 };
1074
1075 self.config_cache.write().await.insert(search_dir, entry);
1076
1077 config
1078 }
1079
1080 fn detect_code_fence_language_position(text: &str, position: Position) -> Option<(u32, String)> {
1091 let line_num = position.line as usize;
1092 let char_pos = position.character as usize;
1093
1094 let lines: Vec<&str> = text.lines().collect();
1096 if line_num >= lines.len() {
1097 return None;
1098 }
1099 let line = lines[line_num];
1100 let trimmed = line.trim_start();
1101 let indent = line.len() - trimmed.len();
1102
1103 let (fence_char, fence_len) = if trimmed.starts_with('`') {
1105 let count = trimmed.chars().take_while(|&c| c == '`').count();
1106 if count >= 3 {
1107 ('`', count)
1108 } else {
1109 return None;
1110 }
1111 } else if trimmed.starts_with('~') {
1112 let count = trimmed.chars().take_while(|&c| c == '~').count();
1113 if count >= 3 {
1114 ('~', count)
1115 } else {
1116 return None;
1117 }
1118 } else {
1119 return None;
1120 };
1121
1122 let fence_start = indent;
1123 let fence_end = fence_start + fence_len;
1124
1125 if char_pos < fence_end {
1127 return None;
1128 }
1129
1130 let is_closing_fence = Self::is_closing_fence(&lines[..line_num], fence_char, fence_len);
1133 if is_closing_fence {
1134 return None;
1135 }
1136
1137 let current_text = if char_pos <= line.len() {
1139 &line[fence_end..char_pos]
1140 } else {
1141 &line[fence_end..]
1142 };
1143
1144 if current_text.contains(' ') {
1146 return None;
1147 }
1148
1149 Some((fence_end as u32, current_text.to_string()))
1150 }
1151
1152 fn is_closing_fence(previous_lines: &[&str], fence_char: char, fence_len: usize) -> bool {
1154 let mut open_fences: Vec<(char, usize)> = Vec::new();
1155
1156 for line in previous_lines {
1157 let trimmed = line.trim_start();
1158
1159 let (line_fence_char, line_fence_len) = if trimmed.starts_with('`') {
1161 let count = trimmed.chars().take_while(|&c| c == '`').count();
1162 if count >= 3 { ('`', count) } else { continue }
1163 } else if trimmed.starts_with('~') {
1164 let count = trimmed.chars().take_while(|&c| c == '~').count();
1165 if count >= 3 { ('~', count) } else { continue }
1166 } else {
1167 continue;
1168 };
1169
1170 if let Some(pos) = open_fences
1172 .iter()
1173 .rposition(|(c, len)| *c == line_fence_char && line_fence_len >= *len)
1174 {
1175 let after_fence = &trimmed[line_fence_len..].trim();
1177 if after_fence.is_empty() {
1178 open_fences.truncate(pos);
1179 continue;
1180 }
1181 }
1182
1183 open_fences.push((line_fence_char, line_fence_len));
1185 }
1186
1187 open_fences.iter().any(|(c, len)| *c == fence_char && fence_len >= *len)
1189 }
1190
1191 async fn get_language_completions(
1195 &self,
1196 uri: &Url,
1197 current_text: &str,
1198 start_col: u32,
1199 position: Position,
1200 ) -> Vec<CompletionItem> {
1201 let file_path = uri.to_file_path().ok();
1203 let config = if let Some(ref path) = file_path {
1204 self.resolve_config_for_file(path).await
1205 } else {
1206 self.rumdl_config.read().await.clone()
1207 };
1208
1209 let md040_config: MD040Config = load_rule_config(&config);
1211
1212 let mut items = Vec::new();
1213 let current_lower = current_text.to_lowercase();
1214
1215 let mut language_entries: Vec<(String, String, bool)> = Vec::new(); for (canonical, aliases) in CANONICAL_TO_ALIASES.iter() {
1219 if !md040_config.allowed_languages.is_empty()
1221 && !md040_config
1222 .allowed_languages
1223 .iter()
1224 .any(|a| a.eq_ignore_ascii_case(canonical))
1225 {
1226 continue;
1227 }
1228
1229 if md040_config
1231 .disallowed_languages
1232 .iter()
1233 .any(|d| d.eq_ignore_ascii_case(canonical))
1234 {
1235 continue;
1236 }
1237
1238 let preferred = md040_config
1240 .preferred_aliases
1241 .iter()
1242 .find(|(k, _)| k.eq_ignore_ascii_case(canonical))
1243 .map(|(_, v)| v.clone())
1244 .or_else(|| default_alias(canonical).map(|s| s.to_string()))
1245 .unwrap_or_else(|| (*canonical).to_string());
1246
1247 language_entries.push(((*canonical).to_string(), preferred.clone(), true));
1249
1250 for &alias in aliases.iter() {
1252 if alias != preferred {
1253 language_entries.push(((*canonical).to_string(), alias.to_string(), false));
1254 }
1255 }
1256 }
1257
1258 for (canonical, alias, is_default) in language_entries {
1260 if !current_text.is_empty() && !alias.to_lowercase().starts_with(¤t_lower) {
1261 continue;
1262 }
1263
1264 let sort_priority = if is_default { "0" } else { "1" };
1265
1266 let item = CompletionItem {
1267 label: alias.clone(),
1268 kind: Some(CompletionItemKind::VALUE),
1269 detail: Some(format!("{canonical} (GitHub Linguist)")),
1270 documentation: None,
1271 sort_text: Some(format!("{sort_priority}{alias}")),
1272 filter_text: Some(alias.clone()),
1273 insert_text: Some(alias.clone()),
1274 text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1275 range: Range {
1276 start: Position {
1277 line: position.line,
1278 character: start_col,
1279 },
1280 end: position,
1281 },
1282 new_text: alias,
1283 })),
1284 ..Default::default()
1285 };
1286 items.push(item);
1287 }
1288
1289 items.truncate(100);
1291 items
1292 }
1293}
1294
1295#[tower_lsp::async_trait]
1296impl LanguageServer for RumdlLanguageServer {
1297 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1298 log::info!("Initializing rumdl Language Server");
1299
1300 if let Some(options) = params.initialization_options
1302 && let Ok(config) = serde_json::from_value::<RumdlLspConfig>(options)
1303 {
1304 *self.config.write().await = config;
1305 }
1306
1307 let supports_pull = params
1310 .capabilities
1311 .text_document
1312 .as_ref()
1313 .and_then(|td| td.diagnostic.as_ref())
1314 .is_some();
1315
1316 if supports_pull {
1317 log::info!("Client supports pull diagnostics - disabling push to avoid duplicates");
1318 *self.client_supports_pull_diagnostics.write().await = true;
1319 } else {
1320 log::info!("Client does not support pull diagnostics - using push model");
1321 }
1322
1323 let mut roots = Vec::new();
1325 if let Some(workspace_folders) = params.workspace_folders {
1326 for folder in workspace_folders {
1327 if let Ok(path) = folder.uri.to_file_path() {
1328 log::info!("Workspace root: {}", path.display());
1329 roots.push(path);
1330 }
1331 }
1332 } else if let Some(root_uri) = params.root_uri
1333 && let Ok(path) = root_uri.to_file_path()
1334 {
1335 log::info!("Workspace root: {}", path.display());
1336 roots.push(path);
1337 }
1338 *self.workspace_roots.write().await = roots;
1339
1340 self.load_configuration(false).await;
1342
1343 Ok(InitializeResult {
1344 capabilities: ServerCapabilities {
1345 text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions {
1346 open_close: Some(true),
1347 change: Some(TextDocumentSyncKind::FULL),
1348 will_save: Some(false),
1349 will_save_wait_until: Some(true),
1350 save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
1351 include_text: Some(false),
1352 })),
1353 })),
1354 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
1355 code_action_kinds: Some(vec![
1356 CodeActionKind::QUICKFIX,
1357 CodeActionKind::SOURCE_FIX_ALL,
1358 CodeActionKind::new("source.fixAll.rumdl"),
1359 ]),
1360 work_done_progress_options: WorkDoneProgressOptions::default(),
1361 resolve_provider: None,
1362 })),
1363 document_formatting_provider: Some(OneOf::Left(true)),
1364 document_range_formatting_provider: Some(OneOf::Left(true)),
1365 diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
1366 identifier: Some("rumdl".to_string()),
1367 inter_file_dependencies: true,
1368 workspace_diagnostics: false,
1369 work_done_progress_options: WorkDoneProgressOptions::default(),
1370 })),
1371 completion_provider: Some(CompletionOptions {
1372 trigger_characters: Some(vec!["`".to_string()]),
1373 resolve_provider: Some(false),
1374 work_done_progress_options: WorkDoneProgressOptions::default(),
1375 all_commit_characters: None,
1376 completion_item: None,
1377 }),
1378 workspace: Some(WorkspaceServerCapabilities {
1379 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1380 supported: Some(true),
1381 change_notifications: Some(OneOf::Left(true)),
1382 }),
1383 file_operations: None,
1384 }),
1385 ..Default::default()
1386 },
1387 server_info: Some(ServerInfo {
1388 name: "rumdl".to_string(),
1389 version: Some(env!("CARGO_PKG_VERSION").to_string()),
1390 }),
1391 })
1392 }
1393
1394 async fn initialized(&self, _: InitializedParams) {
1395 let version = env!("CARGO_PKG_VERSION");
1396
1397 let (binary_path, build_time) = std::env::current_exe()
1399 .ok()
1400 .map(|path| {
1401 let path_str = path.to_str().unwrap_or("unknown").to_string();
1402 let build_time = std::fs::metadata(&path)
1403 .ok()
1404 .and_then(|metadata| metadata.modified().ok())
1405 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
1406 .and_then(|duration| {
1407 let secs = duration.as_secs();
1408 chrono::DateTime::from_timestamp(secs as i64, 0)
1409 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1410 })
1411 .unwrap_or_else(|| "unknown".to_string());
1412 (path_str, build_time)
1413 })
1414 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
1415
1416 let working_dir = std::env::current_dir()
1417 .ok()
1418 .and_then(|p| p.to_str().map(|s| s.to_string()))
1419 .unwrap_or_else(|| "unknown".to_string());
1420
1421 log::info!("rumdl Language Server v{version} initialized (built: {build_time}, binary: {binary_path})");
1422 log::info!("Working directory: {working_dir}");
1423
1424 self.client
1425 .log_message(MessageType::INFO, format!("rumdl v{version} Language Server started"))
1426 .await;
1427
1428 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1430 log::warn!("Failed to trigger initial workspace indexing");
1431 } else {
1432 log::info!("Triggered initial workspace indexing for cross-file analysis");
1433 }
1434
1435 let markdown_patterns = [
1438 "**/*.md",
1439 "**/*.markdown",
1440 "**/*.mdx",
1441 "**/*.mkd",
1442 "**/*.mkdn",
1443 "**/*.mdown",
1444 "**/*.mdwn",
1445 "**/*.qmd",
1446 "**/*.rmd",
1447 ];
1448 let watchers: Vec<_> = markdown_patterns
1449 .iter()
1450 .map(|pattern| FileSystemWatcher {
1451 glob_pattern: GlobPattern::String((*pattern).to_string()),
1452 kind: Some(WatchKind::all()),
1453 })
1454 .collect();
1455
1456 let registration = Registration {
1457 id: "markdown-watcher".to_string(),
1458 method: "workspace/didChangeWatchedFiles".to_string(),
1459 register_options: Some(
1460 serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { watchers }).unwrap(),
1461 ),
1462 };
1463
1464 if self.client.register_capability(vec![registration]).await.is_err() {
1465 log::debug!("Client does not support file watching capability");
1466 }
1467 }
1468
1469 async fn completion(&self, params: CompletionParams) -> JsonRpcResult<Option<CompletionResponse>> {
1470 let uri = params.text_document_position.text_document.uri;
1471 let position = params.text_document_position.position;
1472
1473 let Some(text) = self.get_document_content(&uri).await else {
1475 return Ok(None);
1476 };
1477
1478 let Some((start_col, current_text)) = Self::detect_code_fence_language_position(&text, position) else {
1480 return Ok(None);
1481 };
1482
1483 log::debug!(
1484 "Code fence completion triggered at {}:{}, current text: '{}'",
1485 position.line,
1486 position.character,
1487 current_text
1488 );
1489
1490 let items = self
1492 .get_language_completions(&uri, ¤t_text, start_col, position)
1493 .await;
1494
1495 if items.is_empty() {
1496 Ok(None)
1497 } else {
1498 Ok(Some(CompletionResponse::Array(items)))
1499 }
1500 }
1501
1502 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1503 let mut roots = self.workspace_roots.write().await;
1505
1506 for removed in ¶ms.event.removed {
1508 if let Ok(path) = removed.uri.to_file_path() {
1509 roots.retain(|r| r != &path);
1510 log::info!("Removed workspace root: {}", path.display());
1511 }
1512 }
1513
1514 for added in ¶ms.event.added {
1516 if let Ok(path) = added.uri.to_file_path()
1517 && !roots.contains(&path)
1518 {
1519 log::info!("Added workspace root: {}", path.display());
1520 roots.push(path);
1521 }
1522 }
1523 drop(roots);
1524
1525 self.config_cache.write().await.clear();
1527
1528 self.reload_configuration().await;
1530
1531 if self.update_tx.send(IndexUpdate::FullRescan).await.is_err() {
1533 log::warn!("Failed to trigger workspace rescan after folder change");
1534 }
1535 }
1536
1537 async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
1538 log::debug!("Configuration changed: {:?}", params.settings);
1539
1540 let settings_value = params.settings;
1544
1545 let rumdl_settings = if let serde_json::Value::Object(ref obj) = settings_value {
1547 obj.get("rumdl").cloned().unwrap_or(settings_value.clone())
1548 } else {
1549 settings_value
1550 };
1551
1552 let mut config_applied = false;
1554 let mut warnings: Vec<String> = Vec::new();
1555
1556 if let Ok(rule_settings) = serde_json::from_value::<LspRuleSettings>(rumdl_settings.clone())
1560 && (rule_settings.disable.is_some()
1561 || rule_settings.enable.is_some()
1562 || rule_settings.line_length.is_some()
1563 || !rule_settings.rules.is_empty())
1564 {
1565 if let Some(ref disable) = rule_settings.disable {
1567 for rule in disable {
1568 if !is_valid_rule_name(rule) {
1569 warnings.push(format!("Unknown rule in disable list: {rule}"));
1570 }
1571 }
1572 }
1573 if let Some(ref enable) = rule_settings.enable {
1574 for rule in enable {
1575 if !is_valid_rule_name(rule) {
1576 warnings.push(format!("Unknown rule in enable list: {rule}"));
1577 }
1578 }
1579 }
1580 for rule_name in rule_settings.rules.keys() {
1582 if !is_valid_rule_name(rule_name) {
1583 warnings.push(format!("Unknown rule in settings: {rule_name}"));
1584 }
1585 }
1586
1587 log::info!("Applied rule settings from configuration (Neovim style)");
1588 let mut config = self.config.write().await;
1589 config.settings = Some(rule_settings);
1590 drop(config);
1591 config_applied = true;
1592 } else if let Ok(full_config) = serde_json::from_value::<RumdlLspConfig>(rumdl_settings.clone())
1593 && (full_config.config_path.is_some()
1594 || full_config.enable_rules.is_some()
1595 || full_config.disable_rules.is_some()
1596 || full_config.settings.is_some()
1597 || !full_config.enable_linting
1598 || full_config.enable_auto_fix)
1599 {
1600 if let Some(ref rules) = full_config.enable_rules {
1602 for rule in rules {
1603 if !is_valid_rule_name(rule) {
1604 warnings.push(format!("Unknown rule in enableRules: {rule}"));
1605 }
1606 }
1607 }
1608 if let Some(ref rules) = full_config.disable_rules {
1609 for rule in rules {
1610 if !is_valid_rule_name(rule) {
1611 warnings.push(format!("Unknown rule in disableRules: {rule}"));
1612 }
1613 }
1614 }
1615
1616 log::info!("Applied full LSP configuration from settings");
1617 *self.config.write().await = full_config;
1618 config_applied = true;
1619 } else if let serde_json::Value::Object(obj) = rumdl_settings {
1620 let mut config = self.config.write().await;
1623
1624 let mut rules = std::collections::HashMap::new();
1626 let mut disable = Vec::new();
1627 let mut enable = Vec::new();
1628 let mut line_length = None;
1629
1630 for (key, value) in obj {
1631 match key.as_str() {
1632 "disable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1633 Ok(d) => {
1634 if d.len() > MAX_RULE_LIST_SIZE {
1635 warnings.push(format!(
1636 "Too many rules in 'disable' ({} > {}), truncating",
1637 d.len(),
1638 MAX_RULE_LIST_SIZE
1639 ));
1640 }
1641 for rule in d.iter().take(MAX_RULE_LIST_SIZE) {
1642 if !is_valid_rule_name(rule) {
1643 warnings.push(format!("Unknown rule in disable: {rule}"));
1644 }
1645 }
1646 disable = d.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1647 }
1648 Err(_) => {
1649 warnings.push(format!(
1650 "Invalid 'disable' value: expected array of strings, got {value}"
1651 ));
1652 }
1653 },
1654 "enable" => match serde_json::from_value::<Vec<String>>(value.clone()) {
1655 Ok(e) => {
1656 if e.len() > MAX_RULE_LIST_SIZE {
1657 warnings.push(format!(
1658 "Too many rules in 'enable' ({} > {}), truncating",
1659 e.len(),
1660 MAX_RULE_LIST_SIZE
1661 ));
1662 }
1663 for rule in e.iter().take(MAX_RULE_LIST_SIZE) {
1664 if !is_valid_rule_name(rule) {
1665 warnings.push(format!("Unknown rule in enable: {rule}"));
1666 }
1667 }
1668 enable = e.into_iter().take(MAX_RULE_LIST_SIZE).collect();
1669 }
1670 Err(_) => {
1671 warnings.push(format!(
1672 "Invalid 'enable' value: expected array of strings, got {value}"
1673 ));
1674 }
1675 },
1676 "lineLength" | "line_length" | "line-length" => {
1677 if let Some(l) = value.as_u64() {
1678 match usize::try_from(l) {
1679 Ok(len) if len <= MAX_LINE_LENGTH => line_length = Some(len),
1680 Ok(len) => warnings.push(format!(
1681 "Invalid 'lineLength' value: {len} exceeds maximum ({MAX_LINE_LENGTH})"
1682 )),
1683 Err(_) => warnings.push(format!("Invalid 'lineLength' value: {l} is too large")),
1684 }
1685 } else {
1686 warnings.push(format!("Invalid 'lineLength' value: expected number, got {value}"));
1687 }
1688 }
1689 _ if key.starts_with("MD") || key.starts_with("md") => {
1691 let normalized = key.to_uppercase();
1692 if !is_valid_rule_name(&normalized) {
1693 warnings.push(format!("Unknown rule: {key}"));
1694 }
1695 rules.insert(normalized, value);
1696 }
1697 _ => {
1698 warnings.push(format!("Unknown configuration key: {key}"));
1700 }
1701 }
1702 }
1703
1704 let settings = LspRuleSettings {
1705 line_length,
1706 disable: if disable.is_empty() { None } else { Some(disable) },
1707 enable: if enable.is_empty() { None } else { Some(enable) },
1708 rules,
1709 };
1710
1711 log::info!("Applied Neovim-style rule settings (manual parse)");
1712 config.settings = Some(settings);
1713 drop(config);
1714 config_applied = true;
1715 } else {
1716 log::warn!("Could not parse configuration settings: {rumdl_settings:?}");
1717 }
1718
1719 for warning in &warnings {
1721 log::warn!("{warning}");
1722 }
1723
1724 if !warnings.is_empty() {
1726 let message = if warnings.len() == 1 {
1727 format!("rumdl: {}", warnings[0])
1728 } else {
1729 format!("rumdl configuration warnings:\n{}", warnings.join("\n"))
1730 };
1731 self.client.log_message(MessageType::WARNING, message).await;
1732 }
1733
1734 if !config_applied {
1735 log::debug!("No configuration changes applied");
1736 }
1737
1738 self.config_cache.write().await.clear();
1740
1741 let doc_list: Vec<_> = {
1743 let documents = self.documents.read().await;
1744 documents
1745 .iter()
1746 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1747 .collect()
1748 };
1749
1750 let tasks = doc_list.into_iter().map(|(uri, text)| {
1752 let server = self.clone();
1753 tokio::spawn(async move {
1754 server.update_diagnostics(uri, text).await;
1755 })
1756 });
1757
1758 let _ = join_all(tasks).await;
1760 }
1761
1762 async fn shutdown(&self) -> JsonRpcResult<()> {
1763 log::info!("Shutting down rumdl Language Server");
1764
1765 let _ = self.update_tx.send(IndexUpdate::Shutdown).await;
1767
1768 Ok(())
1769 }
1770
1771 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1772 let uri = params.text_document.uri;
1773 let text = params.text_document.text;
1774 let version = params.text_document.version;
1775
1776 let entry = DocumentEntry {
1777 content: text.clone(),
1778 version: Some(version),
1779 from_disk: false,
1780 };
1781 self.documents.write().await.insert(uri.clone(), entry);
1782
1783 if let Ok(path) = uri.to_file_path() {
1785 let _ = self
1786 .update_tx
1787 .send(IndexUpdate::FileChanged {
1788 path,
1789 content: text.clone(),
1790 })
1791 .await;
1792 }
1793
1794 self.update_diagnostics(uri, text).await;
1795 }
1796
1797 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1798 let uri = params.text_document.uri;
1799 let version = params.text_document.version;
1800
1801 if let Some(change) = params.content_changes.into_iter().next() {
1802 let text = change.text;
1803
1804 let entry = DocumentEntry {
1805 content: text.clone(),
1806 version: Some(version),
1807 from_disk: false,
1808 };
1809 self.documents.write().await.insert(uri.clone(), entry);
1810
1811 if let Ok(path) = uri.to_file_path() {
1813 let _ = self
1814 .update_tx
1815 .send(IndexUpdate::FileChanged {
1816 path,
1817 content: text.clone(),
1818 })
1819 .await;
1820 }
1821
1822 self.update_diagnostics(uri, text).await;
1823 }
1824 }
1825
1826 async fn will_save_wait_until(&self, params: WillSaveTextDocumentParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
1827 if params.reason != TextDocumentSaveReason::MANUAL {
1830 return Ok(None);
1831 }
1832
1833 let config_guard = self.config.read().await;
1834 let enable_auto_fix = config_guard.enable_auto_fix;
1835 drop(config_guard);
1836
1837 if !enable_auto_fix {
1838 return Ok(None);
1839 }
1840
1841 let Some(text) = self.get_document_content(¶ms.text_document.uri).await else {
1843 return Ok(None);
1844 };
1845
1846 match self.apply_all_fixes(¶ms.text_document.uri, &text).await {
1848 Ok(Some(fixed_text)) => {
1849 Ok(Some(vec![TextEdit {
1851 range: Range {
1852 start: Position { line: 0, character: 0 },
1853 end: self.get_end_position(&text),
1854 },
1855 new_text: fixed_text,
1856 }]))
1857 }
1858 Ok(None) => Ok(None),
1859 Err(e) => {
1860 log::error!("Failed to generate fixes in will_save_wait_until: {e}");
1861 Ok(None)
1862 }
1863 }
1864 }
1865
1866 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1867 if let Some(entry) = self.documents.read().await.get(¶ms.text_document.uri) {
1870 self.update_diagnostics(params.text_document.uri, entry.content.clone())
1871 .await;
1872 }
1873 }
1874
1875 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1876 self.documents.write().await.remove(¶ms.text_document.uri);
1878
1879 self.client
1882 .publish_diagnostics(params.text_document.uri, Vec::new(), None)
1883 .await;
1884 }
1885
1886 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1887 const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"];
1889
1890 let mut config_changed = false;
1891
1892 for change in ¶ms.changes {
1893 if let Ok(path) = change.uri.to_file_path() {
1894 let file_name = path.file_name().and_then(|f| f.to_str());
1895 let extension = path.extension().and_then(|e| e.to_str());
1896
1897 if let Some(name) = file_name
1899 && CONFIG_FILES.contains(&name)
1900 && !config_changed
1901 {
1902 log::info!("Config file changed: {}, invalidating config cache", path.display());
1903
1904 let mut cache = self.config_cache.write().await;
1906 cache.retain(|_, entry| {
1907 if let Some(config_file) = &entry.config_file {
1908 config_file != &path
1909 } else {
1910 true
1911 }
1912 });
1913
1914 drop(cache);
1916 self.reload_configuration().await;
1917 config_changed = true;
1918 }
1919
1920 if let Some(ext) = extension
1922 && is_markdown_extension(ext)
1923 {
1924 match change.typ {
1925 FileChangeType::CREATED | FileChangeType::CHANGED => {
1926 if let Ok(content) = tokio::fs::read_to_string(&path).await {
1928 let _ = self
1929 .update_tx
1930 .send(IndexUpdate::FileChanged {
1931 path: path.clone(),
1932 content,
1933 })
1934 .await;
1935 }
1936 }
1937 FileChangeType::DELETED => {
1938 let _ = self
1939 .update_tx
1940 .send(IndexUpdate::FileDeleted { path: path.clone() })
1941 .await;
1942 }
1943 _ => {}
1944 }
1945 }
1946 }
1947 }
1948
1949 if config_changed {
1951 let docs_to_update: Vec<(Url, String)> = {
1952 let docs = self.documents.read().await;
1953 docs.iter()
1954 .filter(|(_, entry)| !entry.from_disk)
1955 .map(|(uri, entry)| (uri.clone(), entry.content.clone()))
1956 .collect()
1957 };
1958
1959 for (uri, text) in docs_to_update {
1960 self.update_diagnostics(uri, text).await;
1961 }
1962 }
1963 }
1964
1965 async fn code_action(&self, params: CodeActionParams) -> JsonRpcResult<Option<CodeActionResponse>> {
1966 let uri = params.text_document.uri;
1967 let range = params.range;
1968 let requested_kinds = params.context.only;
1969
1970 if let Some(text) = self.get_document_content(&uri).await {
1971 match self.get_code_actions(&uri, &text, range).await {
1972 Ok(actions) => {
1973 let filtered_actions = if let Some(ref kinds) = requested_kinds
1977 && !kinds.is_empty()
1978 {
1979 actions
1980 .into_iter()
1981 .filter(|action| {
1982 action.kind.as_ref().is_some_and(|action_kind| {
1983 let action_kind_str = action_kind.as_str();
1984 kinds.iter().any(|requested| {
1985 let requested_str = requested.as_str();
1986 action_kind_str.starts_with(requested_str)
1989 })
1990 })
1991 })
1992 .collect()
1993 } else {
1994 actions
1995 };
1996
1997 let response: Vec<CodeActionOrCommand> = filtered_actions
1998 .into_iter()
1999 .map(CodeActionOrCommand::CodeAction)
2000 .collect();
2001 Ok(Some(response))
2002 }
2003 Err(e) => {
2004 log::error!("Failed to get code actions: {e}");
2005 Ok(None)
2006 }
2007 }
2008 } else {
2009 Ok(None)
2010 }
2011 }
2012
2013 async fn range_formatting(&self, params: DocumentRangeFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2014 log::debug!(
2019 "Range formatting requested for {:?}, formatting entire document due to rule interdependencies",
2020 params.range
2021 );
2022
2023 let formatting_params = DocumentFormattingParams {
2024 text_document: params.text_document,
2025 options: params.options,
2026 work_done_progress_params: params.work_done_progress_params,
2027 };
2028
2029 self.formatting(formatting_params).await
2030 }
2031
2032 async fn formatting(&self, params: DocumentFormattingParams) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2033 let uri = params.text_document.uri;
2034 let options = params.options;
2035
2036 log::debug!("Formatting request for: {uri}");
2037 log::debug!(
2038 "FormattingOptions: insert_final_newline={:?}, trim_final_newlines={:?}, trim_trailing_whitespace={:?}",
2039 options.insert_final_newline,
2040 options.trim_final_newlines,
2041 options.trim_trailing_whitespace
2042 );
2043
2044 if let Some(text) = self.get_document_content(&uri).await {
2045 let config_guard = self.config.read().await;
2047 let lsp_config = config_guard.clone();
2048 drop(config_guard);
2049
2050 let file_path = uri.to_file_path().ok();
2052 let file_config = if let Some(ref path) = file_path {
2053 self.resolve_config_for_file(path).await
2054 } else {
2055 self.rumdl_config.read().await.clone()
2057 };
2058
2059 let rumdl_config = self.merge_lsp_settings(file_config, &lsp_config);
2061
2062 let all_rules = rules::all_rules(&rumdl_config);
2063 let flavor = if let Some(ref path) = file_path {
2064 rumdl_config.get_flavor_for_file(path)
2065 } else {
2066 rumdl_config.markdown_flavor()
2067 };
2068
2069 let mut filtered_rules = rules::filter_rules(&all_rules, &rumdl_config.global);
2071
2072 filtered_rules = self.apply_lsp_config_overrides(filtered_rules, &lsp_config);
2074
2075 let mut result = text.clone();
2077 match crate::lint(&text, &filtered_rules, false, flavor, Some(&rumdl_config)) {
2078 Ok(warnings) => {
2079 log::debug!(
2080 "Found {} warnings, {} with fixes",
2081 warnings.len(),
2082 warnings.iter().filter(|w| w.fix.is_some()).count()
2083 );
2084
2085 let has_fixes = warnings.iter().any(|w| w.fix.is_some());
2086 if has_fixes {
2087 let fixable_warnings: Vec<_> = warnings
2089 .iter()
2090 .filter(|w| {
2091 if let Some(rule_name) = &w.rule_name {
2092 filtered_rules
2093 .iter()
2094 .find(|r| r.name() == rule_name)
2095 .map(|r| r.fix_capability() != FixCapability::Unfixable)
2096 .unwrap_or(false)
2097 } else {
2098 false
2099 }
2100 })
2101 .cloned()
2102 .collect();
2103
2104 match crate::utils::fix_utils::apply_warning_fixes(&text, &fixable_warnings) {
2105 Ok(fixed_content) => {
2106 result = fixed_content;
2107 }
2108 Err(e) => {
2109 log::error!("Failed to apply fixes: {e}");
2110 }
2111 }
2112 }
2113 }
2114 Err(e) => {
2115 log::error!("Failed to lint document: {e}");
2116 }
2117 }
2118
2119 result = Self::apply_formatting_options(result, &options);
2122
2123 if result != text {
2125 log::debug!("Returning formatting edits");
2126 let end_position = self.get_end_position(&text);
2127 let edit = TextEdit {
2128 range: Range {
2129 start: Position { line: 0, character: 0 },
2130 end: end_position,
2131 },
2132 new_text: result,
2133 };
2134 return Ok(Some(vec![edit]));
2135 }
2136
2137 Ok(Some(Vec::new()))
2138 } else {
2139 log::warn!("Document not found: {uri}");
2140 Ok(None)
2141 }
2142 }
2143
2144 async fn diagnostic(&self, params: DocumentDiagnosticParams) -> JsonRpcResult<DocumentDiagnosticReportResult> {
2145 let uri = params.text_document.uri;
2146
2147 if let Some(text) = self.get_open_document_content(&uri).await {
2148 match self.lint_document(&uri, &text).await {
2149 Ok(diagnostics) => Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2150 RelatedFullDocumentDiagnosticReport {
2151 related_documents: None,
2152 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2153 result_id: None,
2154 items: diagnostics,
2155 },
2156 },
2157 ))),
2158 Err(e) => {
2159 log::error!("Failed to get diagnostics: {e}");
2160 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2161 RelatedFullDocumentDiagnosticReport {
2162 related_documents: None,
2163 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2164 result_id: None,
2165 items: Vec::new(),
2166 },
2167 },
2168 )))
2169 }
2170 }
2171 } else {
2172 Ok(DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
2173 RelatedFullDocumentDiagnosticReport {
2174 related_documents: None,
2175 full_document_diagnostic_report: FullDocumentDiagnosticReport {
2176 result_id: None,
2177 items: Vec::new(),
2178 },
2179 },
2180 )))
2181 }
2182 }
2183}
2184
2185#[cfg(test)]
2186mod tests {
2187 use super::*;
2188 use crate::rule::LintWarning;
2189 use tower_lsp::LspService;
2190
2191 fn create_test_server() -> RumdlLanguageServer {
2192 let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
2193 service.inner().clone()
2194 }
2195
2196 #[test]
2197 fn test_is_valid_rule_name() {
2198 assert!(is_valid_rule_name("MD001"));
2200 assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
2204 assert!(is_valid_rule_name("MD005"));
2205 assert!(is_valid_rule_name("MD007"));
2206 assert!(is_valid_rule_name("MD009"));
2207 assert!(is_valid_rule_name("MD041"));
2208 assert!(is_valid_rule_name("MD060"));
2209 assert!(is_valid_rule_name("MD061"));
2210
2211 assert!(is_valid_rule_name("all"));
2213 assert!(is_valid_rule_name("ALL"));
2214 assert!(is_valid_rule_name("All"));
2215
2216 assert!(is_valid_rule_name("line-length")); assert!(is_valid_rule_name("LINE-LENGTH")); assert!(is_valid_rule_name("heading-increment")); assert!(is_valid_rule_name("no-bare-urls")); assert!(is_valid_rule_name("ul-style")); assert!(is_valid_rule_name("ul_style")); assert!(!is_valid_rule_name("MD000")); assert!(!is_valid_rule_name("MD999")); assert!(!is_valid_rule_name("MD100")); assert!(!is_valid_rule_name("INVALID"));
2229 assert!(!is_valid_rule_name("not-a-rule"));
2230 assert!(!is_valid_rule_name(""));
2231 assert!(!is_valid_rule_name("random-text"));
2232 }
2233
2234 #[tokio::test]
2235 async fn test_server_creation() {
2236 let server = create_test_server();
2237
2238 let config = server.config.read().await;
2240 assert!(config.enable_linting);
2241 assert!(!config.enable_auto_fix);
2242 }
2243
2244 #[tokio::test]
2245 async fn test_lint_document() {
2246 let server = create_test_server();
2247
2248 let uri = Url::parse("file:///test.md").unwrap();
2250 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2251
2252 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2253
2254 assert!(!diagnostics.is_empty());
2256 assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
2257 }
2258
2259 #[tokio::test]
2260 async fn test_lint_document_disabled() {
2261 let server = create_test_server();
2262
2263 server.config.write().await.enable_linting = false;
2265
2266 let uri = Url::parse("file:///test.md").unwrap();
2267 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2268
2269 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2270
2271 assert!(diagnostics.is_empty());
2273 }
2274
2275 #[tokio::test]
2276 async fn test_get_code_actions() {
2277 let server = create_test_server();
2278
2279 let uri = Url::parse("file:///test.md").unwrap();
2280 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2281
2282 let range = Range {
2284 start: Position { line: 0, character: 0 },
2285 end: Position { line: 3, character: 21 },
2286 };
2287
2288 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2289
2290 assert!(!actions.is_empty());
2292 assert!(actions.iter().any(|a| a.title.contains("trailing")));
2293 }
2294
2295 #[tokio::test]
2296 async fn test_get_code_actions_outside_range() {
2297 let server = create_test_server();
2298
2299 let uri = Url::parse("file:///test.md").unwrap();
2300 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2301
2302 let range = Range {
2304 start: Position { line: 0, character: 0 },
2305 end: Position { line: 0, character: 6 },
2306 };
2307
2308 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2309
2310 assert!(actions.is_empty());
2312 }
2313
2314 #[tokio::test]
2315 async fn test_document_storage() {
2316 let server = create_test_server();
2317
2318 let uri = Url::parse("file:///test.md").unwrap();
2319 let text = "# Test Document";
2320
2321 let entry = DocumentEntry {
2323 content: text.to_string(),
2324 version: Some(1),
2325 from_disk: false,
2326 };
2327 server.documents.write().await.insert(uri.clone(), entry);
2328
2329 let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
2331 assert_eq!(stored, Some(text.to_string()));
2332
2333 server.documents.write().await.remove(&uri);
2335
2336 let stored = server.documents.read().await.get(&uri).cloned();
2338 assert_eq!(stored, None);
2339 }
2340
2341 #[tokio::test]
2342 async fn test_configuration_loading() {
2343 let server = create_test_server();
2344
2345 server.load_configuration(false).await;
2347
2348 let rumdl_config = server.rumdl_config.read().await;
2351 drop(rumdl_config); }
2354
2355 #[tokio::test]
2356 async fn test_load_config_for_lsp() {
2357 let result = RumdlLanguageServer::load_config_for_lsp(None);
2359 assert!(result.is_ok());
2360
2361 let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
2363 assert!(result.is_err());
2364 }
2365
2366 #[tokio::test]
2367 async fn test_warning_conversion() {
2368 let warning = LintWarning {
2369 message: "Test warning".to_string(),
2370 line: 1,
2371 column: 1,
2372 end_line: 1,
2373 end_column: 10,
2374 severity: crate::rule::Severity::Warning,
2375 fix: None,
2376 rule_name: Some("MD001".to_string()),
2377 };
2378
2379 let diagnostic = warning_to_diagnostic(&warning);
2381 assert_eq!(diagnostic.message, "Test warning");
2382 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
2383 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
2384
2385 let uri = Url::parse("file:///test.md").unwrap();
2387 let actions = warning_to_code_actions(&warning, &uri, "Test content");
2388 assert_eq!(actions.len(), 1);
2390 assert_eq!(actions[0].title, "Ignore MD001 for this line");
2391 }
2392
2393 #[tokio::test]
2394 async fn test_multiple_documents() {
2395 let server = create_test_server();
2396
2397 let uri1 = Url::parse("file:///test1.md").unwrap();
2398 let uri2 = Url::parse("file:///test2.md").unwrap();
2399 let text1 = "# Document 1";
2400 let text2 = "# Document 2";
2401
2402 {
2404 let mut docs = server.documents.write().await;
2405 let entry1 = DocumentEntry {
2406 content: text1.to_string(),
2407 version: Some(1),
2408 from_disk: false,
2409 };
2410 let entry2 = DocumentEntry {
2411 content: text2.to_string(),
2412 version: Some(1),
2413 from_disk: false,
2414 };
2415 docs.insert(uri1.clone(), entry1);
2416 docs.insert(uri2.clone(), entry2);
2417 }
2418
2419 let docs = server.documents.read().await;
2421 assert_eq!(docs.len(), 2);
2422 assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
2423 assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
2424 }
2425
2426 #[tokio::test]
2427 async fn test_auto_fix_on_save() {
2428 let server = create_test_server();
2429
2430 {
2432 let mut config = server.config.write().await;
2433 config.enable_auto_fix = true;
2434 }
2435
2436 let uri = Url::parse("file:///test.md").unwrap();
2437 let text = "#Heading without space"; let entry = DocumentEntry {
2441 content: text.to_string(),
2442 version: Some(1),
2443 from_disk: false,
2444 };
2445 server.documents.write().await.insert(uri.clone(), entry);
2446
2447 let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
2449 assert!(fixed.is_some());
2450 assert_eq!(fixed.unwrap(), "# Heading without space\n");
2452 }
2453
2454 #[tokio::test]
2455 async fn test_get_end_position() {
2456 let server = create_test_server();
2457
2458 let pos = server.get_end_position("Hello");
2460 assert_eq!(pos.line, 0);
2461 assert_eq!(pos.character, 5);
2462
2463 let pos = server.get_end_position("Hello\nWorld\nTest");
2465 assert_eq!(pos.line, 2);
2466 assert_eq!(pos.character, 4);
2467
2468 let pos = server.get_end_position("");
2470 assert_eq!(pos.line, 0);
2471 assert_eq!(pos.character, 0);
2472
2473 let pos = server.get_end_position("Hello\n");
2475 assert_eq!(pos.line, 1);
2476 assert_eq!(pos.character, 0);
2477 }
2478
2479 #[tokio::test]
2480 async fn test_empty_document_handling() {
2481 let server = create_test_server();
2482
2483 let uri = Url::parse("file:///empty.md").unwrap();
2484 let text = "";
2485
2486 let diagnostics = server.lint_document(&uri, text).await.unwrap();
2488 assert!(diagnostics.is_empty());
2489
2490 let range = Range {
2492 start: Position { line: 0, character: 0 },
2493 end: Position { line: 0, character: 0 },
2494 };
2495 let actions = server.get_code_actions(&uri, text, range).await.unwrap();
2496 assert!(actions.is_empty());
2497 }
2498
2499 #[tokio::test]
2500 async fn test_config_update() {
2501 let server = create_test_server();
2502
2503 {
2505 let mut config = server.config.write().await;
2506 config.enable_auto_fix = true;
2507 config.config_path = Some("/custom/path.toml".to_string());
2508 }
2509
2510 let config = server.config.read().await;
2512 assert!(config.enable_auto_fix);
2513 assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
2514 }
2515
2516 #[tokio::test]
2517 async fn test_document_formatting() {
2518 let server = create_test_server();
2519 let uri = Url::parse("file:///test.md").unwrap();
2520 let text = "# Test\n\nThis is a test \nWith trailing spaces ";
2521
2522 let entry = DocumentEntry {
2524 content: text.to_string(),
2525 version: Some(1),
2526 from_disk: false,
2527 };
2528 server.documents.write().await.insert(uri.clone(), entry);
2529
2530 let params = DocumentFormattingParams {
2532 text_document: TextDocumentIdentifier { uri: uri.clone() },
2533 options: FormattingOptions {
2534 tab_size: 4,
2535 insert_spaces: true,
2536 properties: HashMap::new(),
2537 trim_trailing_whitespace: Some(true),
2538 insert_final_newline: Some(true),
2539 trim_final_newlines: Some(true),
2540 },
2541 work_done_progress_params: WorkDoneProgressParams::default(),
2542 };
2543
2544 let result = server.formatting(params).await.unwrap();
2546
2547 assert!(result.is_some());
2549 let edits = result.unwrap();
2550 assert!(!edits.is_empty());
2551
2552 let edit = &edits[0];
2555 let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
2559 assert_eq!(edit.new_text, expected);
2560 }
2561
2562 #[tokio::test]
2565 async fn test_unfixable_rules_excluded_from_formatting() {
2566 let server = create_test_server();
2567 let uri = Url::parse("file:///test.md").unwrap();
2568
2569 let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
2571
2572 let entry = DocumentEntry {
2574 content: text.to_string(),
2575 version: Some(1),
2576 from_disk: false,
2577 };
2578 server.documents.write().await.insert(uri.clone(), entry);
2579
2580 let format_params = DocumentFormattingParams {
2582 text_document: TextDocumentIdentifier { uri: uri.clone() },
2583 options: FormattingOptions {
2584 tab_size: 4,
2585 insert_spaces: true,
2586 properties: HashMap::new(),
2587 trim_trailing_whitespace: Some(true),
2588 insert_final_newline: Some(true),
2589 trim_final_newlines: Some(true),
2590 },
2591 work_done_progress_params: WorkDoneProgressParams::default(),
2592 };
2593
2594 let format_result = server.formatting(format_params).await.unwrap();
2595 assert!(format_result.is_some(), "Should return formatting edits");
2596
2597 let edits = format_result.unwrap();
2598 assert!(!edits.is_empty(), "Should have formatting edits");
2599
2600 let formatted = &edits[0].new_text;
2601 assert!(
2602 formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
2603 "HTML should be preserved during formatting (Unfixable rule)"
2604 );
2605 assert!(
2606 !formatted.contains("spaces "),
2607 "Trailing spaces should be removed (fixable rule)"
2608 );
2609
2610 let range = Range {
2612 start: Position { line: 0, character: 0 },
2613 end: Position { line: 10, character: 0 },
2614 };
2615
2616 let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
2617
2618 let html_fix_actions: Vec<_> = code_actions
2620 .iter()
2621 .filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
2622 .collect();
2623
2624 assert!(
2625 !html_fix_actions.is_empty(),
2626 "Quick Fix actions should be available for HTML (Unfixable rules)"
2627 );
2628
2629 let fix_all_actions: Vec<_> = code_actions
2631 .iter()
2632 .filter(|action| action.title.contains("Fix all"))
2633 .collect();
2634
2635 if let Some(fix_all_action) = fix_all_actions.first()
2636 && let Some(ref edit) = fix_all_action.edit
2637 && let Some(ref changes) = edit.changes
2638 && let Some(text_edits) = changes.get(&uri)
2639 && let Some(text_edit) = text_edits.first()
2640 {
2641 let fixed_all = &text_edit.new_text;
2642 assert!(
2643 fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
2644 "Fix All should preserve HTML (Unfixable rules)"
2645 );
2646 assert!(
2647 !fixed_all.contains("spaces "),
2648 "Fix All should remove trailing spaces (fixable rules)"
2649 );
2650 }
2651 }
2652
2653 #[tokio::test]
2655 async fn test_resolve_config_for_file_multi_root() {
2656 use std::fs;
2657 use tempfile::tempdir;
2658
2659 let temp_dir = tempdir().unwrap();
2660 let temp_path = temp_dir.path();
2661
2662 let project_a = temp_path.join("project_a");
2664 let project_a_docs = project_a.join("docs");
2665 fs::create_dir_all(&project_a_docs).unwrap();
2666
2667 let config_a = project_a.join(".rumdl.toml");
2668 fs::write(
2669 &config_a,
2670 r#"
2671[global]
2672
2673[MD013]
2674line_length = 60
2675"#,
2676 )
2677 .unwrap();
2678
2679 let project_b = temp_path.join("project_b");
2681 fs::create_dir(&project_b).unwrap();
2682
2683 let config_b = project_b.join(".rumdl.toml");
2684 fs::write(
2685 &config_b,
2686 r#"
2687[global]
2688
2689[MD013]
2690line_length = 120
2691"#,
2692 )
2693 .unwrap();
2694
2695 let server = create_test_server();
2697
2698 {
2700 let mut roots = server.workspace_roots.write().await;
2701 roots.push(project_a.clone());
2702 roots.push(project_b.clone());
2703 }
2704
2705 let file_a = project_a_docs.join("test.md");
2707 fs::write(&file_a, "# Test A\n").unwrap();
2708
2709 let config_for_a = server.resolve_config_for_file(&file_a).await;
2710 let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
2711 assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
2712
2713 let file_b = project_b.join("test.md");
2715 fs::write(&file_b, "# Test B\n").unwrap();
2716
2717 let config_for_b = server.resolve_config_for_file(&file_b).await;
2718 let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
2719 assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
2720 }
2721
2722 #[tokio::test]
2724 async fn test_config_resolution_respects_workspace_boundaries() {
2725 use std::fs;
2726 use tempfile::tempdir;
2727
2728 let temp_dir = tempdir().unwrap();
2729 let temp_path = temp_dir.path();
2730
2731 let parent_config = temp_path.join(".rumdl.toml");
2733 fs::write(
2734 &parent_config,
2735 r#"
2736[global]
2737
2738[MD013]
2739line_length = 80
2740"#,
2741 )
2742 .unwrap();
2743
2744 let workspace_root = temp_path.join("workspace");
2746 let workspace_subdir = workspace_root.join("subdir");
2747 fs::create_dir_all(&workspace_subdir).unwrap();
2748
2749 let workspace_config = workspace_root.join(".rumdl.toml");
2750 fs::write(
2751 &workspace_config,
2752 r#"
2753[global]
2754
2755[MD013]
2756line_length = 100
2757"#,
2758 )
2759 .unwrap();
2760
2761 let server = create_test_server();
2762
2763 {
2765 let mut roots = server.workspace_roots.write().await;
2766 roots.push(workspace_root.clone());
2767 }
2768
2769 let test_file = workspace_subdir.join("deep").join("test.md");
2771 fs::create_dir_all(test_file.parent().unwrap()).unwrap();
2772 fs::write(&test_file, "# Test\n").unwrap();
2773
2774 let config = server.resolve_config_for_file(&test_file).await;
2775 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2776
2777 assert_eq!(
2779 line_length,
2780 Some(100),
2781 "Should find workspace config, not parent config outside workspace"
2782 );
2783 }
2784
2785 #[tokio::test]
2787 async fn test_config_cache_hit() {
2788 use std::fs;
2789 use tempfile::tempdir;
2790
2791 let temp_dir = tempdir().unwrap();
2792 let temp_path = temp_dir.path();
2793
2794 let project = temp_path.join("project");
2795 fs::create_dir(&project).unwrap();
2796
2797 let config_file = project.join(".rumdl.toml");
2798 fs::write(
2799 &config_file,
2800 r#"
2801[global]
2802
2803[MD013]
2804line_length = 75
2805"#,
2806 )
2807 .unwrap();
2808
2809 let server = create_test_server();
2810 {
2811 let mut roots = server.workspace_roots.write().await;
2812 roots.push(project.clone());
2813 }
2814
2815 let test_file = project.join("test.md");
2816 fs::write(&test_file, "# Test\n").unwrap();
2817
2818 let config1 = server.resolve_config_for_file(&test_file).await;
2820 let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
2821 assert_eq!(line_length1, Some(75));
2822
2823 {
2825 let cache = server.config_cache.read().await;
2826 let search_dir = test_file.parent().unwrap();
2827 assert!(
2828 cache.contains_key(search_dir),
2829 "Cache should be populated after first call"
2830 );
2831 }
2832
2833 let config2 = server.resolve_config_for_file(&test_file).await;
2835 let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
2836 assert_eq!(line_length2, Some(75));
2837 }
2838
2839 #[tokio::test]
2841 async fn test_nested_directory_config_search() {
2842 use std::fs;
2843 use tempfile::tempdir;
2844
2845 let temp_dir = tempdir().unwrap();
2846 let temp_path = temp_dir.path();
2847
2848 let project = temp_path.join("project");
2849 fs::create_dir(&project).unwrap();
2850
2851 let config = project.join(".rumdl.toml");
2853 fs::write(
2854 &config,
2855 r#"
2856[global]
2857
2858[MD013]
2859line_length = 110
2860"#,
2861 )
2862 .unwrap();
2863
2864 let deep_dir = project.join("src").join("docs").join("guides");
2866 fs::create_dir_all(&deep_dir).unwrap();
2867 let deep_file = deep_dir.join("test.md");
2868 fs::write(&deep_file, "# Test\n").unwrap();
2869
2870 let server = create_test_server();
2871 {
2872 let mut roots = server.workspace_roots.write().await;
2873 roots.push(project.clone());
2874 }
2875
2876 let resolved_config = server.resolve_config_for_file(&deep_file).await;
2877 let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
2878
2879 assert_eq!(
2880 line_length,
2881 Some(110),
2882 "Should find config by searching upward from deep directory"
2883 );
2884 }
2885
2886 #[tokio::test]
2888 async fn test_fallback_to_default_config() {
2889 use std::fs;
2890 use tempfile::tempdir;
2891
2892 let temp_dir = tempdir().unwrap();
2893 let temp_path = temp_dir.path();
2894
2895 let project = temp_path.join("project");
2896 fs::create_dir(&project).unwrap();
2897
2898 let test_file = project.join("test.md");
2901 fs::write(&test_file, "# Test\n").unwrap();
2902
2903 let server = create_test_server();
2904 {
2905 let mut roots = server.workspace_roots.write().await;
2906 roots.push(project.clone());
2907 }
2908
2909 let config = server.resolve_config_for_file(&test_file).await;
2910
2911 assert_eq!(
2913 config.global.line_length.get(),
2914 80,
2915 "Should fall back to default config when no config file found"
2916 );
2917 }
2918
2919 #[tokio::test]
2921 async fn test_config_priority_closer_wins() {
2922 use std::fs;
2923 use tempfile::tempdir;
2924
2925 let temp_dir = tempdir().unwrap();
2926 let temp_path = temp_dir.path();
2927
2928 let project = temp_path.join("project");
2929 fs::create_dir(&project).unwrap();
2930
2931 let parent_config = project.join(".rumdl.toml");
2933 fs::write(
2934 &parent_config,
2935 r#"
2936[global]
2937
2938[MD013]
2939line_length = 100
2940"#,
2941 )
2942 .unwrap();
2943
2944 let subdir = project.join("subdir");
2946 fs::create_dir(&subdir).unwrap();
2947
2948 let subdir_config = subdir.join(".rumdl.toml");
2949 fs::write(
2950 &subdir_config,
2951 r#"
2952[global]
2953
2954[MD013]
2955line_length = 50
2956"#,
2957 )
2958 .unwrap();
2959
2960 let server = create_test_server();
2961 {
2962 let mut roots = server.workspace_roots.write().await;
2963 roots.push(project.clone());
2964 }
2965
2966 let test_file = subdir.join("test.md");
2968 fs::write(&test_file, "# Test\n").unwrap();
2969
2970 let config = server.resolve_config_for_file(&test_file).await;
2971 let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
2972
2973 assert_eq!(
2974 line_length,
2975 Some(50),
2976 "Closer config (subdir) should override parent config"
2977 );
2978 }
2979
2980 #[tokio::test]
2986 async fn test_issue_131_pyproject_without_rumdl_section() {
2987 use std::fs;
2988 use tempfile::tempdir;
2989
2990 let parent_dir = tempdir().unwrap();
2992
2993 let project_dir = parent_dir.path().join("project");
2995 fs::create_dir(&project_dir).unwrap();
2996
2997 fs::write(
2999 project_dir.join("pyproject.toml"),
3000 r#"
3001[project]
3002name = "test-project"
3003version = "0.1.0"
3004"#,
3005 )
3006 .unwrap();
3007
3008 fs::write(
3011 parent_dir.path().join(".rumdl.toml"),
3012 r#"
3013[global]
3014disable = ["MD013"]
3015"#,
3016 )
3017 .unwrap();
3018
3019 let test_file = project_dir.join("test.md");
3020 fs::write(&test_file, "# Test\n").unwrap();
3021
3022 let server = create_test_server();
3023
3024 {
3026 let mut roots = server.workspace_roots.write().await;
3027 roots.push(parent_dir.path().to_path_buf());
3028 }
3029
3030 let config = server.resolve_config_for_file(&test_file).await;
3032
3033 assert!(
3036 config.global.disable.contains(&"MD013".to_string()),
3037 "Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
3038 and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
3039 );
3040
3041 let cache = server.config_cache.read().await;
3044 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
3045
3046 assert!(
3047 cache_entry.config_file.is_some(),
3048 "Should have found a config file (parent .rumdl.toml)"
3049 );
3050
3051 let found_config_path = cache_entry.config_file.as_ref().unwrap();
3052 assert!(
3053 found_config_path.ends_with(".rumdl.toml"),
3054 "Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
3055 );
3056 assert!(
3057 found_config_path.parent().unwrap() == parent_dir.path(),
3058 "Should have loaded config from parent directory, not project_dir"
3059 );
3060 }
3061
3062 #[tokio::test]
3067 async fn test_issue_131_pyproject_with_rumdl_section() {
3068 use std::fs;
3069 use tempfile::tempdir;
3070
3071 let parent_dir = tempdir().unwrap();
3073
3074 let project_dir = parent_dir.path().join("project");
3076 fs::create_dir(&project_dir).unwrap();
3077
3078 fs::write(
3080 project_dir.join("pyproject.toml"),
3081 r#"
3082[project]
3083name = "test-project"
3084
3085[tool.rumdl.global]
3086disable = ["MD033"]
3087"#,
3088 )
3089 .unwrap();
3090
3091 fs::write(
3093 parent_dir.path().join(".rumdl.toml"),
3094 r#"
3095[global]
3096disable = ["MD041"]
3097"#,
3098 )
3099 .unwrap();
3100
3101 let test_file = project_dir.join("test.md");
3102 fs::write(&test_file, "# Test\n").unwrap();
3103
3104 let server = create_test_server();
3105
3106 {
3108 let mut roots = server.workspace_roots.write().await;
3109 roots.push(parent_dir.path().to_path_buf());
3110 }
3111
3112 let config = server.resolve_config_for_file(&test_file).await;
3114
3115 assert!(
3117 config.global.disable.contains(&"MD033".to_string()),
3118 "Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
3119 Expected MD033 from project_dir pyproject.toml to be disabled."
3120 );
3121
3122 assert!(
3124 !config.global.disable.contains(&"MD041".to_string()),
3125 "Should use project_dir pyproject.toml, not parent .rumdl.toml"
3126 );
3127
3128 let cache = server.config_cache.read().await;
3130 let cache_entry = cache.get(&project_dir).expect("Config should be cached");
3131
3132 assert!(cache_entry.config_file.is_some(), "Should have found a config file");
3133
3134 let found_config_path = cache_entry.config_file.as_ref().unwrap();
3135 assert!(
3136 found_config_path.ends_with("pyproject.toml"),
3137 "Should have loaded pyproject.toml. Found: {found_config_path:?}"
3138 );
3139 assert!(
3140 found_config_path.parent().unwrap() == project_dir,
3141 "Should have loaded pyproject.toml from project_dir, not parent"
3142 );
3143 }
3144
3145 #[tokio::test]
3150 async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
3151 use std::fs;
3152 use tempfile::tempdir;
3153
3154 let temp_dir = tempdir().unwrap();
3155
3156 fs::write(
3158 temp_dir.path().join("pyproject.toml"),
3159 r#"
3160[project]
3161name = "test-project"
3162
3163[tool.rumdl.global]
3164disable = ["MD022"]
3165"#,
3166 )
3167 .unwrap();
3168
3169 let test_file = temp_dir.path().join("test.md");
3170 fs::write(&test_file, "# Test\n").unwrap();
3171
3172 let server = create_test_server();
3173
3174 {
3176 let mut roots = server.workspace_roots.write().await;
3177 roots.push(temp_dir.path().to_path_buf());
3178 }
3179
3180 let config = server.resolve_config_for_file(&test_file).await;
3182
3183 assert!(
3185 config.global.disable.contains(&"MD022".to_string()),
3186 "Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
3187 );
3188
3189 let cache = server.config_cache.read().await;
3191 let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
3192 assert!(
3193 cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
3194 "Should have loaded pyproject.toml"
3195 );
3196 }
3197
3198 #[tokio::test]
3203 async fn test_issue_182_pull_diagnostics_capability_default() {
3204 let server = create_test_server();
3205
3206 assert!(
3208 !*server.client_supports_pull_diagnostics.read().await,
3209 "Default should be false - push diagnostics by default"
3210 );
3211 }
3212
3213 #[tokio::test]
3215 async fn test_issue_182_pull_diagnostics_flag_update() {
3216 let server = create_test_server();
3217
3218 *server.client_supports_pull_diagnostics.write().await = true;
3220
3221 assert!(
3222 *server.client_supports_pull_diagnostics.read().await,
3223 "Flag should be settable to true"
3224 );
3225 }
3226
3227 #[tokio::test]
3231 async fn test_issue_182_capability_detection_with_diagnostic_support() {
3232 use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
3233
3234 let caps_with_diagnostic = ClientCapabilities {
3236 text_document: Some(TextDocumentClientCapabilities {
3237 diagnostic: Some(DiagnosticClientCapabilities {
3238 dynamic_registration: Some(true),
3239 related_document_support: Some(false),
3240 }),
3241 ..Default::default()
3242 }),
3243 ..Default::default()
3244 };
3245
3246 let supports_pull = caps_with_diagnostic
3248 .text_document
3249 .as_ref()
3250 .and_then(|td| td.diagnostic.as_ref())
3251 .is_some();
3252
3253 assert!(supports_pull, "Should detect pull diagnostic support");
3254 }
3255
3256 #[tokio::test]
3258 async fn test_issue_182_capability_detection_without_diagnostic_support() {
3259 use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
3260
3261 let caps_without_diagnostic = ClientCapabilities {
3263 text_document: Some(TextDocumentClientCapabilities {
3264 diagnostic: None, ..Default::default()
3266 }),
3267 ..Default::default()
3268 };
3269
3270 let supports_pull = caps_without_diagnostic
3272 .text_document
3273 .as_ref()
3274 .and_then(|td| td.diagnostic.as_ref())
3275 .is_some();
3276
3277 assert!(!supports_pull, "Should NOT detect pull diagnostic support");
3278 }
3279
3280 #[tokio::test]
3282 async fn test_issue_182_capability_detection_no_text_document() {
3283 use tower_lsp::lsp_types::ClientCapabilities;
3284
3285 let caps_no_text_doc = ClientCapabilities {
3287 text_document: None,
3288 ..Default::default()
3289 };
3290
3291 let supports_pull = caps_no_text_doc
3293 .text_document
3294 .as_ref()
3295 .and_then(|td| td.diagnostic.as_ref())
3296 .is_some();
3297
3298 assert!(
3299 !supports_pull,
3300 "Should NOT detect pull diagnostic support when text_document is None"
3301 );
3302 }
3303
3304 #[test]
3305 fn test_resource_limit_constants() {
3306 assert_eq!(MAX_RULE_LIST_SIZE, 100);
3308 assert_eq!(MAX_LINE_LENGTH, 10_000);
3309 }
3310
3311 #[test]
3312 fn test_is_valid_rule_name_edge_cases() {
3313 assert!(!is_valid_rule_name("MD/01")); assert!(!is_valid_rule_name("MD:01")); assert!(!is_valid_rule_name("ND001")); assert!(!is_valid_rule_name("ME001")); assert!(!is_valid_rule_name("MD0â‘ 1")); assert!(!is_valid_rule_name("ï¼D001")); assert!(!is_valid_rule_name("MD\x00\x00\x00")); }
3326
3327 #[tokio::test]
3336 async fn test_lsp_toml_config_parity_generic() {
3337 use crate::config::RuleConfig;
3338 use crate::rule::Severity;
3339
3340 let server = create_test_server();
3341
3342 let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
3346 (
3348 "severity only - error",
3349 serde_json::json!({"severity": "error"}),
3350 RuleConfig {
3351 severity: Some(Severity::Error),
3352 values: std::collections::BTreeMap::new(),
3353 },
3354 ),
3355 (
3356 "severity only - warning",
3357 serde_json::json!({"severity": "warning"}),
3358 RuleConfig {
3359 severity: Some(Severity::Warning),
3360 values: std::collections::BTreeMap::new(),
3361 },
3362 ),
3363 (
3364 "severity only - info",
3365 serde_json::json!({"severity": "info"}),
3366 RuleConfig {
3367 severity: Some(Severity::Info),
3368 values: std::collections::BTreeMap::new(),
3369 },
3370 ),
3371 (
3373 "integer value",
3374 serde_json::json!({"lineLength": 120}),
3375 RuleConfig {
3376 severity: None,
3377 values: [("line_length".to_string(), toml::Value::Integer(120))]
3378 .into_iter()
3379 .collect(),
3380 },
3381 ),
3382 (
3384 "boolean value",
3385 serde_json::json!({"enabled": true}),
3386 RuleConfig {
3387 severity: None,
3388 values: [("enabled".to_string(), toml::Value::Boolean(true))]
3389 .into_iter()
3390 .collect(),
3391 },
3392 ),
3393 (
3395 "string value",
3396 serde_json::json!({"style": "consistent"}),
3397 RuleConfig {
3398 severity: None,
3399 values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
3400 .into_iter()
3401 .collect(),
3402 },
3403 ),
3404 (
3406 "array value",
3407 serde_json::json!({"allowedElements": ["div", "span"]}),
3408 RuleConfig {
3409 severity: None,
3410 values: [(
3411 "allowed_elements".to_string(),
3412 toml::Value::Array(vec![
3413 toml::Value::String("div".to_string()),
3414 toml::Value::String("span".to_string()),
3415 ]),
3416 )]
3417 .into_iter()
3418 .collect(),
3419 },
3420 ),
3421 (
3423 "severity + integer",
3424 serde_json::json!({"severity": "info", "lineLength": 80}),
3425 RuleConfig {
3426 severity: Some(Severity::Info),
3427 values: [("line_length".to_string(), toml::Value::Integer(80))]
3428 .into_iter()
3429 .collect(),
3430 },
3431 ),
3432 (
3433 "severity + multiple values",
3434 serde_json::json!({
3435 "severity": "warning",
3436 "lineLength": 100,
3437 "strict": false,
3438 "style": "atx"
3439 }),
3440 RuleConfig {
3441 severity: Some(Severity::Warning),
3442 values: [
3443 ("line_length".to_string(), toml::Value::Integer(100)),
3444 ("strict".to_string(), toml::Value::Boolean(false)),
3445 ("style".to_string(), toml::Value::String("atx".to_string())),
3446 ]
3447 .into_iter()
3448 .collect(),
3449 },
3450 ),
3451 (
3453 "camelCase conversion",
3454 serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
3455 RuleConfig {
3456 severity: None,
3457 values: [
3458 ("code_blocks".to_string(), toml::Value::Boolean(true)),
3459 ("heading_style".to_string(), toml::Value::String("setext".to_string())),
3460 ]
3461 .into_iter()
3462 .collect(),
3463 },
3464 ),
3465 ];
3466
3467 for (description, lsp_json, expected_toml_config) in test_configs {
3468 let mut lsp_config = crate::config::Config::default();
3469 server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
3470
3471 let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
3472
3473 assert_eq!(
3475 lsp_rule.severity, expected_toml_config.severity,
3476 "Parity failure [{description}]: severity mismatch. \
3477 LSP={:?}, TOML={:?}",
3478 lsp_rule.severity, expected_toml_config.severity
3479 );
3480
3481 assert_eq!(
3483 lsp_rule.values, expected_toml_config.values,
3484 "Parity failure [{description}]: values mismatch. \
3485 LSP={:?}, TOML={:?}",
3486 lsp_rule.values, expected_toml_config.values
3487 );
3488 }
3489 }
3490
3491 #[tokio::test]
3493 async fn test_lsp_config_if_absent_preserves_existing() {
3494 use crate::config::RuleConfig;
3495 use crate::rule::Severity;
3496
3497 let server = create_test_server();
3498
3499 let mut config = crate::config::Config::default();
3501 config.rules.insert(
3502 "MD013".to_string(),
3503 RuleConfig {
3504 severity: Some(Severity::Error),
3505 values: [("line_length".to_string(), toml::Value::Integer(80))]
3506 .into_iter()
3507 .collect(),
3508 },
3509 );
3510
3511 let lsp_json = serde_json::json!({
3513 "severity": "info",
3514 "lineLength": 120
3515 });
3516 server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
3517
3518 let rule = config.rules.get("MD013").expect("Rule should exist");
3519
3520 assert_eq!(
3522 rule.severity,
3523 Some(Severity::Error),
3524 "Existing severity should not be overwritten"
3525 );
3526
3527 assert_eq!(
3529 rule.values.get("line_length"),
3530 Some(&toml::Value::Integer(80)),
3531 "Existing values should not be overwritten"
3532 );
3533 }
3534
3535 #[test]
3538 fn test_apply_formatting_options_insert_final_newline() {
3539 let options = FormattingOptions {
3540 tab_size: 4,
3541 insert_spaces: true,
3542 properties: HashMap::new(),
3543 trim_trailing_whitespace: None,
3544 insert_final_newline: Some(true),
3545 trim_final_newlines: None,
3546 };
3547
3548 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3550 assert_eq!(result, "hello\n");
3551
3552 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3554 assert_eq!(result, "hello\n");
3555 }
3556
3557 #[test]
3558 fn test_apply_formatting_options_trim_final_newlines() {
3559 let options = FormattingOptions {
3560 tab_size: 4,
3561 insert_spaces: true,
3562 properties: HashMap::new(),
3563 trim_trailing_whitespace: None,
3564 insert_final_newline: None,
3565 trim_final_newlines: Some(true),
3566 };
3567
3568 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3570 assert_eq!(result, "hello");
3571
3572 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3574 assert_eq!(result, "hello");
3575 }
3576
3577 #[test]
3578 fn test_apply_formatting_options_trim_and_insert_combined() {
3579 let options = FormattingOptions {
3581 tab_size: 4,
3582 insert_spaces: true,
3583 properties: HashMap::new(),
3584 trim_trailing_whitespace: None,
3585 insert_final_newline: Some(true),
3586 trim_final_newlines: Some(true),
3587 };
3588
3589 let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
3591 assert_eq!(result, "hello\n");
3592
3593 let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
3595 assert_eq!(result, "hello\n");
3596
3597 let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
3599 assert_eq!(result, "hello\n");
3600 }
3601
3602 #[test]
3603 fn test_apply_formatting_options_trim_trailing_whitespace() {
3604 let options = FormattingOptions {
3605 tab_size: 4,
3606 insert_spaces: true,
3607 properties: HashMap::new(),
3608 trim_trailing_whitespace: Some(true),
3609 insert_final_newline: Some(true),
3610 trim_final_newlines: None,
3611 };
3612
3613 let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
3615 assert_eq!(result, "hello\nworld\n");
3616 }
3617
3618 #[test]
3619 fn test_apply_formatting_options_issue_265_scenario() {
3620 let options = FormattingOptions {
3625 tab_size: 4,
3626 insert_spaces: true,
3627 properties: HashMap::new(),
3628 trim_trailing_whitespace: None,
3629 insert_final_newline: Some(true),
3630 trim_final_newlines: Some(true),
3631 };
3632
3633 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
3635 assert_eq!(
3636 result, "hello foobar hello.\n",
3637 "Should have exactly one trailing newline"
3638 );
3639
3640 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
3642 assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
3643
3644 let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
3646 assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
3647 }
3648
3649 #[test]
3650 fn test_apply_formatting_options_no_options() {
3651 let options = FormattingOptions {
3653 tab_size: 4,
3654 insert_spaces: true,
3655 properties: HashMap::new(),
3656 trim_trailing_whitespace: None,
3657 insert_final_newline: None,
3658 trim_final_newlines: None,
3659 };
3660
3661 let content = "hello \nworld\n\n\n";
3662 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3663 assert_eq!(result, content, "Content should be unchanged when no options set");
3664 }
3665
3666 #[test]
3667 fn test_apply_formatting_options_empty_content() {
3668 let options = FormattingOptions {
3669 tab_size: 4,
3670 insert_spaces: true,
3671 properties: HashMap::new(),
3672 trim_trailing_whitespace: Some(true),
3673 insert_final_newline: Some(true),
3674 trim_final_newlines: Some(true),
3675 };
3676
3677 let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
3679 assert_eq!(result, "");
3680
3681 let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
3683 assert_eq!(result, "\n");
3684 }
3685
3686 #[test]
3687 fn test_apply_formatting_options_multiline_content() {
3688 let options = FormattingOptions {
3689 tab_size: 4,
3690 insert_spaces: true,
3691 properties: HashMap::new(),
3692 trim_trailing_whitespace: Some(true),
3693 insert_final_newline: Some(true),
3694 trim_final_newlines: Some(true),
3695 };
3696
3697 let content = "# Heading \n\nParagraph \n- List item \n\n\n";
3698 let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
3699 assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
3700 }
3701
3702 #[test]
3703 fn test_code_action_kind_filtering() {
3704 let matches = |action_kind: &str, requested: &str| -> bool { action_kind.starts_with(requested) };
3708
3709 assert!(matches("source.fixAll.rumdl", "source.fixAll"));
3711
3712 assert!(matches("source.fixAll.rumdl", "source.fixAll.rumdl"));
3714
3715 assert!(matches("source.fixAll.rumdl", "source"));
3717
3718 assert!(matches("quickfix", "quickfix"));
3720
3721 assert!(!matches("source.fixAll.rumdl", "quickfix"));
3723
3724 assert!(!matches("quickfix", "source.fixAll"));
3726
3727 assert!(!matches("source.fixAll", "source.fixAll.rumdl"));
3729 }
3730
3731 #[test]
3732 fn test_code_action_kind_filter_with_empty_array() {
3733 let filter_actions = |kinds: Option<Vec<&str>>| -> bool {
3737 if let Some(ref k) = kinds
3739 && !k.is_empty()
3740 {
3741 false
3743 } else {
3744 true
3746 }
3747 };
3748
3749 assert!(filter_actions(None));
3751
3752 assert!(filter_actions(Some(vec![])));
3754
3755 assert!(!filter_actions(Some(vec!["source.fixAll"])));
3757 }
3758
3759 #[test]
3760 fn test_code_action_kind_constants() {
3761 let fix_all_rumdl = CodeActionKind::new("source.fixAll.rumdl");
3763 assert_eq!(fix_all_rumdl.as_str(), "source.fixAll.rumdl");
3764
3765 assert!(
3767 fix_all_rumdl
3768 .as_str()
3769 .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str())
3770 );
3771 }
3772
3773 #[test]
3776 fn test_detect_code_fence_language_position_basic() {
3777 let text = "```\ncode\n```";
3779 let pos = Position { line: 0, character: 3 };
3780 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3781 assert!(result.is_some());
3782 let (start_col, current_text) = result.unwrap();
3783 assert_eq!(start_col, 3);
3784 assert_eq!(current_text, "");
3785 }
3786
3787 #[test]
3788 fn test_detect_code_fence_language_position_partial_lang() {
3789 let text = "```py\ncode\n```";
3791 let pos = Position { line: 0, character: 5 };
3792 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3793 assert!(result.is_some());
3794 let (start_col, current_text) = result.unwrap();
3795 assert_eq!(start_col, 3);
3796 assert_eq!(current_text, "py");
3797 }
3798
3799 #[test]
3800 fn test_detect_code_fence_language_position_full_lang() {
3801 let text = "```python\ncode\n```";
3803 let pos = Position { line: 0, character: 9 };
3804 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3805 assert!(result.is_some());
3806 let (start_col, current_text) = result.unwrap();
3807 assert_eq!(start_col, 3);
3808 assert_eq!(current_text, "python");
3809 }
3810
3811 #[test]
3812 fn test_detect_code_fence_language_position_tilde_fence() {
3813 let text = "~~~rust\ncode\n~~~";
3815 let pos = Position { line: 0, character: 7 };
3816 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3817 assert!(result.is_some());
3818 let (start_col, current_text) = result.unwrap();
3819 assert_eq!(start_col, 3);
3820 assert_eq!(current_text, "rust");
3821 }
3822
3823 #[test]
3824 fn test_detect_code_fence_language_position_indented() {
3825 let text = " ```js\ncode\n ```";
3827 let pos = Position { line: 0, character: 7 };
3828 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3829 assert!(result.is_some());
3830 let (start_col, current_text) = result.unwrap();
3831 assert_eq!(start_col, 5); assert_eq!(current_text, "js");
3833 }
3834
3835 #[test]
3836 fn test_detect_code_fence_language_position_not_fence_line() {
3837 let text = "```python\ncode\n```";
3839 let pos = Position { line: 1, character: 2 };
3840 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3841 assert!(result.is_none());
3842 }
3843
3844 #[test]
3845 fn test_detect_code_fence_language_position_closing_fence() {
3846 let text = "```python\ncode\n```";
3848 let pos = Position { line: 2, character: 3 };
3849 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3850 assert!(result.is_none(), "Should not offer completion on closing fence");
3852 }
3853
3854 #[test]
3855 fn test_detect_code_fence_language_position_extended_fence() {
3856 let text = "````python\ncode\n````";
3858 let pos = Position { line: 0, character: 10 };
3859 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3860 assert!(result.is_some());
3861 let (start_col, current_text) = result.unwrap();
3862 assert_eq!(start_col, 4); assert_eq!(current_text, "python");
3864 }
3865
3866 #[test]
3867 fn test_detect_code_fence_language_position_extended_fence_5_backticks() {
3868 let text = "`````js\ncode\n`````";
3870 let pos = Position { line: 0, character: 7 };
3871 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3872 assert!(result.is_some());
3873 let (start_col, current_text) = result.unwrap();
3874 assert_eq!(start_col, 5);
3875 assert_eq!(current_text, "js");
3876 }
3877
3878 #[test]
3879 fn test_detect_code_fence_language_position_nested_code_blocks() {
3880 let text = "````markdown\n```python\ncode\n```\n````";
3883
3884 let pos = Position { line: 0, character: 12 };
3886 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3887 assert!(result.is_some());
3888 let (_, current_text) = result.unwrap();
3889 assert_eq!(current_text, "markdown");
3890
3891 }
3896
3897 #[test]
3898 fn test_detect_code_fence_language_position_extended_closing_fence() {
3899 let text = "````python\ncode here\n````";
3901 let pos = Position { line: 2, character: 4 };
3902 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3903 assert!(
3904 result.is_none(),
3905 "Should not offer completion on extended closing fence"
3906 );
3907 }
3908
3909 #[test]
3910 fn test_detect_code_fence_language_position_cursor_before_fence() {
3911 let text = "```python\ncode\n```";
3913 let pos = Position { line: 0, character: 2 };
3914 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3915 assert!(result.is_none());
3916 }
3917
3918 #[test]
3919 fn test_detect_code_fence_language_position_with_info_string() {
3920 let text = "```python filename.py\ncode\n```";
3922 let pos = Position { line: 0, character: 15 };
3923 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3924 assert!(result.is_none());
3926 }
3927
3928 #[test]
3929 fn test_detect_code_fence_language_position_regular_text() {
3930 let text = "# Heading\n\nSome text.";
3932 let pos = Position { line: 0, character: 5 };
3933 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3934 assert!(result.is_none());
3935 }
3936
3937 #[test]
3938 fn test_detect_code_fence_language_position_inline_code() {
3939 let text = "Use `code` here.";
3941 let pos = Position { line: 0, character: 5 };
3942 let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
3943 assert!(result.is_none());
3944 }
3945
3946 #[tokio::test]
3947 async fn test_completion_provides_language_items() {
3948 use std::fs;
3949 use tempfile::tempdir;
3950
3951 let temp_dir = tempdir().unwrap();
3952 let test_file = temp_dir.path().join("test.md");
3953 fs::write(&test_file, "```py\ncode\n```").unwrap();
3954
3955 let server = create_test_server();
3956 let uri = Url::from_file_path(&test_file).unwrap();
3957
3958 let content = "```py\ncode\n```".to_string();
3960 server.documents.write().await.insert(
3961 uri.clone(),
3962 DocumentEntry {
3963 content: content.clone(),
3964 version: Some(1),
3965 from_disk: false,
3966 },
3967 );
3968
3969 let items = server
3971 .get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
3972 .await;
3973
3974 assert!(!items.is_empty(), "Should return completion items");
3976
3977 let has_python = items.iter().any(|item| item.label.to_lowercase() == "python");
3979 assert!(has_python, "Should include 'python' as a completion item");
3980 }
3981
3982 #[tokio::test]
3983 async fn test_completion_filters_by_prefix() {
3984 let temp_dir = tempfile::tempdir().unwrap();
3985 let test_file = temp_dir.path().join("test.md");
3986 std::fs::write(&test_file, "```ru\ncode\n```").unwrap();
3987
3988 let server = create_test_server();
3989 let uri = Url::from_file_path(&test_file).unwrap();
3990
3991 let items = server
3993 .get_language_completions(&uri, "ru", 3, Position { line: 0, character: 5 })
3994 .await;
3995
3996 for item in &items {
3998 assert!(
3999 item.label.to_lowercase().starts_with("ru"),
4000 "Completion '{}' should start with 'ru'",
4001 item.label
4002 );
4003 }
4004
4005 let has_rust = items.iter().any(|item| item.label.to_lowercase() == "rust");
4007 let has_ruby = items.iter().any(|item| item.label.to_lowercase() == "ruby");
4008 assert!(has_rust, "Should include 'rust'");
4009 assert!(has_ruby, "Should include 'ruby'");
4010 }
4011
4012 #[tokio::test]
4013 async fn test_completion_empty_prefix_returns_all() {
4014 let temp_dir = tempfile::tempdir().unwrap();
4015 let test_file = temp_dir.path().join("test.md");
4016 std::fs::write(&test_file, "```\ncode\n```").unwrap();
4017
4018 let server = create_test_server();
4019 let uri = Url::from_file_path(&test_file).unwrap();
4020
4021 let items = server
4023 .get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
4024 .await;
4025
4026 assert!(items.len() >= 10, "Should return multiple language options");
4028 assert!(items.len() <= 100, "Should be limited to 100 items");
4029 }
4030
4031 #[tokio::test]
4032 async fn test_completion_respects_md040_allowed_languages() {
4033 use std::fs;
4034
4035 let temp_dir = tempfile::tempdir().unwrap();
4036 let test_file = temp_dir.path().join("test.md");
4037 fs::write(&test_file, "```\ncode\n```").unwrap();
4038
4039 let config_file = temp_dir.path().join(".rumdl.toml");
4041 fs::write(
4042 &config_file,
4043 r#"
4044[MD040]
4045allowed-languages = ["Python", "Rust", "Go"]
4046"#,
4047 )
4048 .unwrap();
4049
4050 let server = create_test_server();
4051
4052 {
4054 let mut roots = server.workspace_roots.write().await;
4055 roots.push(temp_dir.path().to_path_buf());
4056 }
4057
4058 let uri = Url::from_file_path(&test_file).unwrap();
4059
4060 let items = server
4062 .get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
4063 .await;
4064
4065 for item in &items {
4067 let label_lower = item.label.to_lowercase();
4068 let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
4069
4070 let is_allowed = detail.contains("python") || detail.contains("rust") || detail.contains("go");
4072 assert!(
4073 is_allowed,
4074 "Completion '{label_lower}' (detail: '{detail}') should be for Python, Rust, or Go"
4075 );
4076 }
4077 }
4078
4079 #[tokio::test]
4080 async fn test_completion_respects_md040_disallowed_languages() {
4081 use std::fs;
4082
4083 let temp_dir = tempfile::tempdir().unwrap();
4084 let test_file = temp_dir.path().join("test.md");
4085 fs::write(&test_file, "```py\ncode\n```").unwrap();
4086
4087 let config_file = temp_dir.path().join(".rumdl.toml");
4089 fs::write(
4090 &config_file,
4091 r#"
4092[MD040]
4093disallowed-languages = ["Python"]
4094"#,
4095 )
4096 .unwrap();
4097
4098 let server = create_test_server();
4099
4100 {
4102 let mut roots = server.workspace_roots.write().await;
4103 roots.push(temp_dir.path().to_path_buf());
4104 }
4105
4106 let uri = Url::from_file_path(&test_file).unwrap();
4107
4108 let items = server
4110 .get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
4111 .await;
4112
4113 for item in &items {
4115 let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
4116 assert!(
4117 !detail.contains("python"),
4118 "Completion '{}' should not include Python (disallowed)",
4119 item.label
4120 );
4121 }
4122 }
4123
4124 #[test]
4125 fn test_is_closing_fence_basic() {
4126 let lines = vec!["```python"];
4129 assert!(
4130 RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4131 "After opening fence, next fence is closing"
4132 );
4133 }
4134
4135 #[test]
4136 fn test_is_closing_fence_with_content() {
4137 let lines = vec!["```python", "some code"];
4139 assert!(
4140 RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4141 "After opening fence with content, next fence is closing"
4142 );
4143 }
4144
4145 #[test]
4146 fn test_is_closing_fence_no_prior_fence() {
4147 let lines: Vec<&str> = vec!["# Hello", "Some text"];
4149 assert!(
4150 !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4151 "With no prior fence, next fence is opening"
4152 );
4153 }
4154
4155 #[test]
4156 fn test_is_closing_fence_already_closed() {
4157 let lines = vec!["```python", "some code", "```"];
4159 assert!(
4160 !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4161 "After closed code block, next fence is opening"
4162 );
4163 }
4164
4165 #[test]
4166 fn test_is_closing_fence_extended() {
4167 let lines = vec!["````python", "some code"];
4169 assert!(
4171 !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4172 "3 backticks cannot close 4-backtick fence"
4173 );
4174 assert!(
4176 RumdlLanguageServer::is_closing_fence(&lines, '`', 4),
4177 "4 backticks can close 4-backtick fence"
4178 );
4179 assert!(
4181 RumdlLanguageServer::is_closing_fence(&lines, '`', 5),
4182 "5 backticks can close 4-backtick fence"
4183 );
4184 }
4185
4186 #[test]
4187 fn test_is_closing_fence_mixed_chars() {
4188 let lines = vec!["~~~python", "some code"];
4190 assert!(
4191 !RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
4192 "Backtick fence cannot close tilde fence"
4193 );
4194 assert!(
4195 RumdlLanguageServer::is_closing_fence(&lines, '~', 3),
4196 "Tilde fence can close tilde fence"
4197 );
4198 }
4199
4200 #[tokio::test]
4201 async fn test_completion_method_integration() {
4202 use std::fs;
4203
4204 let temp_dir = tempfile::tempdir().unwrap();
4205 let test_file = temp_dir.path().join("test.md");
4206 let content = "# Hello\n\n```py\nprint('hi')\n```";
4207 fs::write(&test_file, content).unwrap();
4208
4209 let server = create_test_server();
4210 let uri = Url::from_file_path(&test_file).unwrap();
4211
4212 server.documents.write().await.insert(
4214 uri.clone(),
4215 DocumentEntry {
4216 content: content.to_string(),
4217 version: Some(1),
4218 from_disk: false,
4219 },
4220 );
4221
4222 let params = CompletionParams {
4224 text_document_position: TextDocumentPositionParams {
4225 text_document: TextDocumentIdentifier { uri: uri.clone() },
4226 position: Position { line: 2, character: 5 }, },
4228 work_done_progress_params: WorkDoneProgressParams::default(),
4229 partial_result_params: PartialResultParams::default(),
4230 context: None,
4231 };
4232
4233 let result = server.completion(params).await.unwrap();
4234 assert!(result.is_some(), "Completion should return items");
4235
4236 if let Some(CompletionResponse::Array(items)) = result {
4237 assert!(!items.is_empty(), "Should have completion items");
4238 let has_python = items.iter().any(|i| i.label.to_lowercase() == "python");
4240 assert!(has_python, "Should include python as completion");
4241 } else {
4242 panic!("Expected CompletionResponse::Array");
4243 }
4244 }
4245
4246 #[tokio::test]
4247 async fn test_completion_not_triggered_on_closing_fence() {
4248 use std::fs;
4249
4250 let temp_dir = tempfile::tempdir().unwrap();
4251 let test_file = temp_dir.path().join("test.md");
4252 let content = "```python\nprint('hi')\n```";
4253 fs::write(&test_file, content).unwrap();
4254
4255 let server = create_test_server();
4256 let uri = Url::from_file_path(&test_file).unwrap();
4257
4258 server.documents.write().await.insert(
4260 uri.clone(),
4261 DocumentEntry {
4262 content: content.to_string(),
4263 version: Some(1),
4264 from_disk: false,
4265 },
4266 );
4267
4268 let params = CompletionParams {
4270 text_document_position: TextDocumentPositionParams {
4271 text_document: TextDocumentIdentifier { uri: uri.clone() },
4272 position: Position { line: 2, character: 3 }, },
4274 work_done_progress_params: WorkDoneProgressParams::default(),
4275 partial_result_params: PartialResultParams::default(),
4276 context: None,
4277 };
4278
4279 let result = server.completion(params).await.unwrap();
4280 assert!(result.is_none(), "Should NOT offer completion on closing fence");
4281 }
4282
4283 #[tokio::test]
4284 async fn test_completion_graceful_when_document_not_found() {
4285 let server = create_test_server();
4286
4287 let uri = Url::parse("file:///nonexistent/path/test.md").unwrap();
4289
4290 let params = CompletionParams {
4291 text_document_position: TextDocumentPositionParams {
4292 text_document: TextDocumentIdentifier { uri },
4293 position: Position { line: 0, character: 3 },
4294 },
4295 work_done_progress_params: WorkDoneProgressParams::default(),
4296 partial_result_params: PartialResultParams::default(),
4297 context: None,
4298 };
4299
4300 let result = server.completion(params).await;
4302 assert!(result.is_ok(), "Completion should not error for missing document");
4303 assert!(result.unwrap().is_none(), "Should return None for missing document");
4304 }
4305}