use super::*;
use crate::lsp::types::{warning_to_code_actions, warning_to_diagnostic};
use crate::rule::LintWarning;
use tower_lsp::LspService;
fn create_test_server() -> RumdlLanguageServer {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
service.inner().clone()
}
#[test]
fn test_is_valid_rule_name() {
assert!(is_valid_rule_name("MD001"));
assert!(is_valid_rule_name("md001")); assert!(is_valid_rule_name("Md001")); assert!(is_valid_rule_name("mD001")); assert!(is_valid_rule_name("MD003"));
assert!(is_valid_rule_name("MD005"));
assert!(is_valid_rule_name("MD007"));
assert!(is_valid_rule_name("MD009"));
assert!(is_valid_rule_name("MD041"));
assert!(is_valid_rule_name("MD060"));
assert!(is_valid_rule_name("MD061"));
assert!(is_valid_rule_name("all"));
assert!(is_valid_rule_name("ALL"));
assert!(is_valid_rule_name("All"));
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"));
assert!(!is_valid_rule_name("not-a-rule"));
assert!(!is_valid_rule_name(""));
assert!(!is_valid_rule_name("random-text"));
}
#[tokio::test]
async fn test_server_creation() {
let server = create_test_server();
let config = server.config.read().await;
assert!(config.enable_linting);
assert!(!config.enable_auto_fix);
}
#[tokio::test]
async fn test_lint_document() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\nThis is a test \nWith trailing spaces ";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
assert!(!diagnostics.is_empty());
assert!(diagnostics.iter().any(|d| d.message.contains("trailing")));
}
#[tokio::test]
async fn test_lint_document_disabled() {
let server = create_test_server();
server.config.write().await.enable_linting = false;
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\nThis is a test \nWith trailing spaces ";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
assert!(diagnostics.is_empty());
}
#[tokio::test]
async fn test_get_code_actions() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\nThis is a test \nWith trailing spaces ";
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 3, character: 21 },
};
let actions = server.get_code_actions(&uri, text, range).await.unwrap();
assert!(!actions.is_empty());
assert!(actions.iter().any(|a| a.title.contains("trailing")));
}
#[tokio::test]
async fn test_source_fix_all_with_single_fixable_issue() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test";
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 6 },
};
let actions = server.get_code_actions(&uri, text, range).await.unwrap();
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"source.fixAll.rumdl should be available even with a single fixable issue"
);
}
#[tokio::test]
async fn test_get_code_actions_outside_range() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n\tThis is a test\n\tWith tabs\n";
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 6 },
};
let actions = server.get_code_actions(&uri, text, range).await.unwrap();
let per_warning_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() != "source.fixAll.rumdl"))
.collect();
assert!(
per_warning_actions.is_empty(),
"No per-warning actions for out-of-range lines"
);
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"fixAll is document-wide and should appear regardless of requested range"
);
}
#[tokio::test]
async fn test_document_storage() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test Document";
let entry = DocumentEntry {
content: text.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let stored = server.documents.read().await.get(&uri).map(|e| e.content.clone());
assert_eq!(stored, Some(text.to_string()));
server.documents.write().await.remove(&uri);
let stored = server.documents.read().await.get(&uri).cloned();
assert_eq!(stored, None);
}
#[tokio::test]
async fn test_configuration_loading() {
let server = create_test_server();
server.load_configuration(false).await;
let rumdl_config = server.rumdl_config.read().await;
drop(rumdl_config); }
#[tokio::test]
async fn test_load_config_for_lsp() {
let result = RumdlLanguageServer::load_config_for_lsp(None);
assert!(result.is_ok());
let result = RumdlLanguageServer::load_config_for_lsp(Some("/nonexistent/config.toml"));
assert!(result.is_err());
}
#[tokio::test]
async fn test_warning_conversion() {
let warning = LintWarning {
message: "Test warning".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 10,
severity: crate::rule::Severity::Warning,
fix: None,
rule_name: Some("MD001".to_string()),
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.message, "Test warning");
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
let uri = Url::parse("file:///test.md").unwrap();
let actions = warning_to_code_actions(&warning, &uri, "Test content");
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].title, "Ignore MD001 for this line");
}
#[tokio::test]
async fn test_multiple_documents() {
let server = create_test_server();
let uri1 = Url::parse("file:///test1.md").unwrap();
let uri2 = Url::parse("file:///test2.md").unwrap();
let text1 = "# Document 1";
let text2 = "# Document 2";
{
let mut docs = server.documents.write().await;
let entry1 = DocumentEntry {
content: text1.to_string(),
version: Some(1),
from_disk: false,
};
let entry2 = DocumentEntry {
content: text2.to_string(),
version: Some(1),
from_disk: false,
};
docs.insert(uri1.clone(), entry1);
docs.insert(uri2.clone(), entry2);
}
let docs = server.documents.read().await;
assert_eq!(docs.len(), 2);
assert_eq!(docs.get(&uri1).map(|s| s.content.as_str()), Some(text1));
assert_eq!(docs.get(&uri2).map(|s| s.content.as_str()), Some(text2));
}
#[tokio::test]
async fn test_auto_fix_on_save() {
let server = create_test_server();
{
let mut config = server.config.write().await;
config.enable_auto_fix = true;
}
let uri = Url::parse("file:///test.md").unwrap();
let text = "#Heading without space";
let entry = DocumentEntry {
content: text.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let fixed = server.apply_all_fixes(&uri, text).await.unwrap();
assert!(fixed.is_some());
assert_eq!(fixed.unwrap(), "# Heading without space\n");
}
#[tokio::test]
async fn test_get_end_position() {
let server = create_test_server();
let pos = server.get_end_position("Hello");
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 5);
let pos = server.get_end_position("Hello\nWorld\nTest");
assert_eq!(pos.line, 2);
assert_eq!(pos.character, 4);
let pos = server.get_end_position("");
assert_eq!(pos.line, 0);
assert_eq!(pos.character, 0);
let pos = server.get_end_position("Hello\n");
assert_eq!(pos.line, 1);
assert_eq!(pos.character, 0);
}
#[tokio::test]
async fn test_empty_document_handling() {
let server = create_test_server();
let uri = Url::parse("file:///empty.md").unwrap();
let text = "";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
assert!(diagnostics.is_empty());
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 0 },
};
let actions = server.get_code_actions(&uri, text, range).await.unwrap();
assert!(actions.is_empty());
}
#[tokio::test]
async fn test_config_update() {
let server = create_test_server();
{
let mut config = server.config.write().await;
config.enable_auto_fix = true;
config.config_path = Some("/custom/path.toml".to_string());
}
let config = server.config.read().await;
assert!(config.enable_auto_fix);
assert_eq!(config.config_path, Some("/custom/path.toml".to_string()));
}
#[tokio::test]
async fn test_document_formatting() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\nThis is a test \nWith trailing spaces ";
let entry = DocumentEntry {
content: text.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let params = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let result = server.formatting(params).await.unwrap();
assert!(result.is_some());
let edits = result.unwrap();
assert!(!edits.is_empty());
let edit = &edits[0];
let expected = "# Test\n\nThis is a test\nWith trailing spaces\n";
assert_eq!(edit.new_text, expected);
}
#[tokio::test]
async fn test_unfixable_rules_excluded_from_formatting() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test Document\n\n<img src=\"test.png\" alt=\"Test\" />\n\nTrailing spaces ";
let entry = DocumentEntry {
content: text.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let format_params = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let format_result = server.formatting(format_params).await.unwrap();
assert!(format_result.is_some(), "Should return formatting edits");
let edits = format_result.unwrap();
assert!(!edits.is_empty(), "Should have formatting edits");
let formatted = &edits[0].new_text;
assert!(
formatted.contains("<img src=\"test.png\" alt=\"Test\" />"),
"HTML should be preserved during formatting (Unfixable rule)"
);
assert!(
!formatted.contains("spaces "),
"Trailing spaces should be removed (fixable rule)"
);
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 10, character: 0 },
};
let code_actions = server.get_code_actions(&uri, text, range).await.unwrap();
let html_fix_actions: Vec<_> = code_actions
.iter()
.filter(|action| action.title.contains("MD033") || action.title.contains("HTML"))
.collect();
assert!(
!html_fix_actions.is_empty(),
"Quick Fix actions should be available for HTML (Unfixable rules)"
);
let fix_all_actions: Vec<_> = code_actions
.iter()
.filter(|action| action.title.contains("Fix all"))
.collect();
if let Some(fix_all_action) = fix_all_actions.first()
&& let Some(ref edit) = fix_all_action.edit
&& let Some(ref changes) = edit.changes
&& let Some(text_edits) = changes.get(&uri)
&& let Some(text_edit) = text_edits.first()
{
let fixed_all = &text_edit.new_text;
assert!(
fixed_all.contains("<img src=\"test.png\" alt=\"Test\" />"),
"Fix All should preserve HTML (Unfixable rules)"
);
assert!(
!fixed_all.contains("spaces "),
"Fix All should remove trailing spaces (fixable rules)"
);
}
}
#[tokio::test]
async fn test_resolve_config_for_file_multi_root() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let project_a = temp_path.join("project_a");
let project_a_docs = project_a.join("docs");
fs::create_dir_all(&project_a_docs).unwrap();
let config_a = project_a.join(".rumdl.toml");
fs::write(
&config_a,
r#"
[global]
[MD013]
line_length = 60
"#,
)
.unwrap();
let project_b = temp_path.join("project_b");
fs::create_dir(&project_b).unwrap();
let config_b = project_b.join(".rumdl.toml");
fs::write(
&config_b,
r#"
[global]
[MD013]
line_length = 120
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project_a.clone());
roots.push(project_b.clone());
}
let file_a = project_a_docs.join("test.md");
fs::write(&file_a, "# Test A\n").unwrap();
let config_for_a = server.resolve_config_for_file(&file_a).await;
let line_length_a = crate::config::get_rule_config_value::<usize>(&config_for_a, "MD013", "line_length");
assert_eq!(line_length_a, Some(60), "File in project_a should get line_length=60");
let file_b = project_b.join("test.md");
fs::write(&file_b, "# Test B\n").unwrap();
let config_for_b = server.resolve_config_for_file(&file_b).await;
let line_length_b = crate::config::get_rule_config_value::<usize>(&config_for_b, "MD013", "line_length");
assert_eq!(line_length_b, Some(120), "File in project_b should get line_length=120");
}
#[tokio::test]
async fn test_config_resolution_respects_workspace_boundaries() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let parent_config = temp_path.join(".rumdl.toml");
fs::write(
&parent_config,
r#"
[global]
[MD013]
line_length = 80
"#,
)
.unwrap();
let workspace_root = temp_path.join("workspace");
let workspace_subdir = workspace_root.join("subdir");
fs::create_dir_all(&workspace_subdir).unwrap();
let workspace_config = workspace_root.join(".rumdl.toml");
fs::write(
&workspace_config,
r#"
[global]
[MD013]
line_length = 100
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(workspace_root.clone());
}
let test_file = workspace_subdir.join("deep").join("test.md");
fs::create_dir_all(test_file.parent().unwrap()).unwrap();
fs::write(&test_file, "# Test\n").unwrap();
let config = server.resolve_config_for_file(&test_file).await;
let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(
line_length,
Some(100),
"Should find workspace config, not parent config outside workspace"
);
}
#[tokio::test]
async fn test_config_cache_hit() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let project = temp_path.join("project");
fs::create_dir(&project).unwrap();
let config_file = project.join(".rumdl.toml");
fs::write(
&config_file,
r#"
[global]
[MD013]
line_length = 75
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let test_file = project.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let config1 = server.resolve_config_for_file(&test_file).await;
let line_length1 = crate::config::get_rule_config_value::<usize>(&config1, "MD013", "line_length");
assert_eq!(line_length1, Some(75));
{
let cache = server.config_cache.read().await;
let search_dir = test_file.parent().unwrap();
assert!(
cache.contains_key(search_dir),
"Cache should be populated after first call"
);
}
let config2 = server.resolve_config_for_file(&test_file).await;
let line_length2 = crate::config::get_rule_config_value::<usize>(&config2, "MD013", "line_length");
assert_eq!(line_length2, Some(75));
}
#[tokio::test]
async fn test_nested_directory_config_search() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let project = temp_path.join("project");
fs::create_dir(&project).unwrap();
let config = project.join(".rumdl.toml");
fs::write(
&config,
r#"
[global]
[MD013]
line_length = 110
"#,
)
.unwrap();
let deep_dir = project.join("src").join("docs").join("guides");
fs::create_dir_all(&deep_dir).unwrap();
let deep_file = deep_dir.join("test.md");
fs::write(&deep_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let resolved_config = server.resolve_config_for_file(&deep_file).await;
let line_length = crate::config::get_rule_config_value::<usize>(&resolved_config, "MD013", "line_length");
assert_eq!(
line_length,
Some(110),
"Should find config by searching upward from deep directory"
);
}
#[tokio::test]
async fn test_fallback_to_default_config() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let project = temp_path.join("project");
fs::create_dir(&project).unwrap();
let test_file = project.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let config = server.resolve_config_for_file(&test_file).await;
assert_eq!(
config.global.line_length.get(),
80,
"Should fall back to default config when no config file found"
);
}
#[tokio::test]
async fn test_config_priority_closer_wins() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
let project = temp_path.join("project");
fs::create_dir(&project).unwrap();
let parent_config = project.join(".rumdl.toml");
fs::write(
&parent_config,
r#"
[global]
[MD013]
line_length = 100
"#,
)
.unwrap();
let subdir = project.join("subdir");
fs::create_dir(&subdir).unwrap();
let subdir_config = subdir.join(".rumdl.toml");
fs::write(
&subdir_config,
r#"
[global]
[MD013]
line_length = 50
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let test_file = subdir.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let config = server.resolve_config_for_file(&test_file).await;
let line_length = crate::config::get_rule_config_value::<usize>(&config, "MD013", "line_length");
assert_eq!(
line_length,
Some(50),
"Closer config (subdir) should override parent config"
);
}
#[tokio::test]
async fn test_issue_131_pyproject_without_rumdl_section() {
use std::fs;
use tempfile::tempdir;
let parent_dir = tempdir().unwrap();
let project_dir = parent_dir.path().join("project");
fs::create_dir(&project_dir).unwrap();
fs::write(
project_dir.join("pyproject.toml"),
r#"
[project]
name = "test-project"
version = "0.1.0"
"#,
)
.unwrap();
fs::write(
parent_dir.path().join(".rumdl.toml"),
r#"
[global]
disable = ["MD013"]
"#,
)
.unwrap();
let test_file = project_dir.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(parent_dir.path().to_path_buf());
}
let config = server.resolve_config_for_file(&test_file).await;
assert!(
config.global.disable.contains(&"MD013".to_string()),
"Issue #131 regression: LSP must skip pyproject.toml without [tool.rumdl] \
and continue upward search. Expected MD013 from parent .rumdl.toml to be disabled."
);
let cache = server.config_cache.read().await;
let cache_entry = cache.get(&project_dir).expect("Config should be cached");
assert!(
cache_entry.config_file.is_some(),
"Should have found a config file (parent .rumdl.toml)"
);
let found_config_path = cache_entry.config_file.as_ref().unwrap();
assert!(
found_config_path.ends_with(".rumdl.toml"),
"Should have loaded .rumdl.toml, not pyproject.toml. Found: {found_config_path:?}"
);
assert!(
found_config_path.parent().unwrap() == parent_dir.path(),
"Should have loaded config from parent directory, not project_dir"
);
}
#[tokio::test]
async fn test_issue_131_pyproject_with_rumdl_section() {
use std::fs;
use tempfile::tempdir;
let parent_dir = tempdir().unwrap();
let project_dir = parent_dir.path().join("project");
fs::create_dir(&project_dir).unwrap();
fs::write(
project_dir.join("pyproject.toml"),
r#"
[project]
name = "test-project"
[tool.rumdl.global]
disable = ["MD033"]
"#,
)
.unwrap();
fs::write(
parent_dir.path().join(".rumdl.toml"),
r#"
[global]
disable = ["MD041"]
"#,
)
.unwrap();
let test_file = project_dir.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(parent_dir.path().to_path_buf());
}
let config = server.resolve_config_for_file(&test_file).await;
assert!(
config.global.disable.contains(&"MD033".to_string()),
"Issue #131 regression: LSP must load pyproject.toml when it has [tool.rumdl]. \
Expected MD033 from project_dir pyproject.toml to be disabled."
);
assert!(
!config.global.disable.contains(&"MD041".to_string()),
"Should use project_dir pyproject.toml, not parent .rumdl.toml"
);
let cache = server.config_cache.read().await;
let cache_entry = cache.get(&project_dir).expect("Config should be cached");
assert!(cache_entry.config_file.is_some(), "Should have found a config file");
let found_config_path = cache_entry.config_file.as_ref().unwrap();
assert!(
found_config_path.ends_with("pyproject.toml"),
"Should have loaded pyproject.toml. Found: {found_config_path:?}"
);
assert!(
found_config_path.parent().unwrap() == project_dir,
"Should have loaded pyproject.toml from project_dir, not parent"
);
}
#[tokio::test]
async fn test_issue_131_pyproject_with_tool_rumdl_subsection() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
fs::write(
temp_dir.path().join("pyproject.toml"),
r#"
[project]
name = "test-project"
[tool.rumdl.global]
disable = ["MD022"]
"#,
)
.unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(temp_dir.path().to_path_buf());
}
let config = server.resolve_config_for_file(&test_file).await;
assert!(
config.global.disable.contains(&"MD022".to_string()),
"Should detect tool.rumdl substring in [tool.rumdl.global] and load config"
);
let cache = server.config_cache.read().await;
let cache_entry = cache.get(temp_dir.path()).expect("Config should be cached");
assert!(
cache_entry.config_file.as_ref().unwrap().ends_with("pyproject.toml"),
"Should have loaded pyproject.toml"
);
}
#[tokio::test]
async fn test_issue_182_pull_diagnostics_capability_default() {
let server = create_test_server();
assert!(
!*server.client_supports_pull_diagnostics.read().await,
"Default should be false - push diagnostics by default"
);
}
#[tokio::test]
async fn test_issue_182_pull_diagnostics_flag_update() {
let server = create_test_server();
*server.client_supports_pull_diagnostics.write().await = true;
assert!(
*server.client_supports_pull_diagnostics.read().await,
"Flag should be settable to true"
);
}
#[tokio::test]
async fn test_issue_182_capability_detection_with_diagnostic_support() {
use tower_lsp::lsp_types::{ClientCapabilities, DiagnosticClientCapabilities, TextDocumentClientCapabilities};
let caps_with_diagnostic = ClientCapabilities {
text_document: Some(TextDocumentClientCapabilities {
diagnostic: Some(DiagnosticClientCapabilities {
dynamic_registration: Some(true),
related_document_support: Some(false),
}),
..Default::default()
}),
..Default::default()
};
let supports_pull = caps_with_diagnostic
.text_document
.as_ref()
.and_then(|td| td.diagnostic.as_ref())
.is_some();
assert!(supports_pull, "Should detect pull diagnostic support");
}
#[tokio::test]
async fn test_issue_182_capability_detection_without_diagnostic_support() {
use tower_lsp::lsp_types::{ClientCapabilities, TextDocumentClientCapabilities};
let caps_without_diagnostic = ClientCapabilities {
text_document: Some(TextDocumentClientCapabilities {
diagnostic: None, ..Default::default()
}),
..Default::default()
};
let supports_pull = caps_without_diagnostic
.text_document
.as_ref()
.and_then(|td| td.diagnostic.as_ref())
.is_some();
assert!(!supports_pull, "Should NOT detect pull diagnostic support");
}
#[tokio::test]
async fn test_issue_182_capability_detection_no_text_document() {
use tower_lsp::lsp_types::ClientCapabilities;
let caps_no_text_doc = ClientCapabilities {
text_document: None,
..Default::default()
};
let supports_pull = caps_no_text_doc
.text_document
.as_ref()
.and_then(|td| td.diagnostic.as_ref())
.is_some();
assert!(
!supports_pull,
"Should NOT detect pull diagnostic support when text_document is None"
);
}
#[test]
fn test_resource_limit_constants() {
assert_eq!(MAX_RULE_LIST_SIZE, 100);
assert_eq!(MAX_LINE_LENGTH, 10_000);
}
#[test]
fn test_is_valid_rule_name_edge_cases() {
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("MD001"));
assert!(!is_valid_rule_name("MD\x00\x00\x00")); }
#[tokio::test]
async fn test_lsp_toml_config_parity_generic() {
use crate::config::RuleConfig;
use crate::rule::Severity;
let server = create_test_server();
let test_configs: Vec<(&str, serde_json::Value, RuleConfig)> = vec![
(
"severity only - error",
serde_json::json!({"severity": "error"}),
RuleConfig {
severity: Some(Severity::Error),
values: std::collections::BTreeMap::new(),
},
),
(
"severity only - warning",
serde_json::json!({"severity": "warning"}),
RuleConfig {
severity: Some(Severity::Warning),
values: std::collections::BTreeMap::new(),
},
),
(
"severity only - info",
serde_json::json!({"severity": "info"}),
RuleConfig {
severity: Some(Severity::Info),
values: std::collections::BTreeMap::new(),
},
),
(
"integer value",
serde_json::json!({"lineLength": 120}),
RuleConfig {
severity: None,
values: [("line_length".to_string(), toml::Value::Integer(120))]
.into_iter()
.collect(),
},
),
(
"boolean value",
serde_json::json!({"enabled": true}),
RuleConfig {
severity: None,
values: [("enabled".to_string(), toml::Value::Boolean(true))]
.into_iter()
.collect(),
},
),
(
"string value",
serde_json::json!({"style": "consistent"}),
RuleConfig {
severity: None,
values: [("style".to_string(), toml::Value::String("consistent".to_string()))]
.into_iter()
.collect(),
},
),
(
"array value",
serde_json::json!({"allowedElements": ["div", "span"]}),
RuleConfig {
severity: None,
values: [(
"allowed_elements".to_string(),
toml::Value::Array(vec![
toml::Value::String("div".to_string()),
toml::Value::String("span".to_string()),
]),
)]
.into_iter()
.collect(),
},
),
(
"severity + integer",
serde_json::json!({"severity": "info", "lineLength": 80}),
RuleConfig {
severity: Some(Severity::Info),
values: [("line_length".to_string(), toml::Value::Integer(80))]
.into_iter()
.collect(),
},
),
(
"severity + multiple values",
serde_json::json!({
"severity": "warning",
"lineLength": 100,
"strict": false,
"style": "atx"
}),
RuleConfig {
severity: Some(Severity::Warning),
values: [
("line_length".to_string(), toml::Value::Integer(100)),
("strict".to_string(), toml::Value::Boolean(false)),
("style".to_string(), toml::Value::String("atx".to_string())),
]
.into_iter()
.collect(),
},
),
(
"camelCase conversion",
serde_json::json!({"codeBlocks": true, "headingStyle": "setext"}),
RuleConfig {
severity: None,
values: [
("code_blocks".to_string(), toml::Value::Boolean(true)),
("heading_style".to_string(), toml::Value::String("setext".to_string())),
]
.into_iter()
.collect(),
},
),
];
for (description, lsp_json, expected_toml_config) in test_configs {
let mut lsp_config = crate::config::Config::default();
server.apply_rule_config(&mut lsp_config, "TEST", &lsp_json);
let lsp_rule = lsp_config.rules.get("TEST").expect("Rule should exist");
assert_eq!(
lsp_rule.severity, expected_toml_config.severity,
"Parity failure [{description}]: severity mismatch. \
LSP={:?}, TOML={:?}",
lsp_rule.severity, expected_toml_config.severity
);
assert_eq!(
lsp_rule.values, expected_toml_config.values,
"Parity failure [{description}]: values mismatch. \
LSP={:?}, TOML={:?}",
lsp_rule.values, expected_toml_config.values
);
}
}
#[tokio::test]
async fn test_lsp_config_if_absent_preserves_existing() {
use crate::config::RuleConfig;
use crate::rule::Severity;
let server = create_test_server();
let mut config = crate::config::Config::default();
config.rules.insert(
"MD013".to_string(),
RuleConfig {
severity: Some(Severity::Error),
values: [("line_length".to_string(), toml::Value::Integer(80))]
.into_iter()
.collect(),
},
);
let lsp_json = serde_json::json!({
"severity": "info",
"lineLength": 120
});
server.apply_rule_config_if_absent(&mut config, "MD013", &lsp_json);
let rule = config.rules.get("MD013").expect("Rule should exist");
assert_eq!(
rule.severity,
Some(Severity::Error),
"Existing severity should not be overwritten"
);
assert_eq!(
rule.values.get("line_length"),
Some(&toml::Value::Integer(80)),
"Existing values should not be overwritten"
);
}
#[test]
fn test_apply_formatting_options_insert_final_newline() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: None,
insert_final_newline: Some(true),
trim_final_newlines: None,
};
let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
assert_eq!(result, "hello\n");
let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
assert_eq!(result, "hello\n");
}
#[test]
fn test_apply_formatting_options_trim_final_newlines() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: None,
insert_final_newline: None,
trim_final_newlines: Some(true),
};
let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
assert_eq!(result, "hello");
let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
assert_eq!(result, "hello");
}
#[test]
fn test_apply_formatting_options_trim_and_insert_combined() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: None,
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
};
let result = RumdlLanguageServer::apply_formatting_options("hello\n\n\n".to_string(), &options);
assert_eq!(result, "hello\n");
let result = RumdlLanguageServer::apply_formatting_options("hello".to_string(), &options);
assert_eq!(result, "hello\n");
let result = RumdlLanguageServer::apply_formatting_options("hello\n".to_string(), &options);
assert_eq!(result, "hello\n");
}
#[test]
fn test_apply_formatting_options_trim_trailing_whitespace() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: None,
};
let result = RumdlLanguageServer::apply_formatting_options("hello \nworld\t\n".to_string(), &options);
assert_eq!(result, "hello\nworld\n");
}
#[test]
fn test_apply_formatting_options_issue_265_scenario() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: None,
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
};
let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n\n\n".to_string(), &options);
assert_eq!(
result, "hello foobar hello.\n",
"Should have exactly one trailing newline"
);
let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.".to_string(), &options);
assert_eq!(result, "hello foobar hello.\n", "Should add final newline");
let result = RumdlLanguageServer::apply_formatting_options("hello foobar hello.\n".to_string(), &options);
assert_eq!(result, "hello foobar hello.\n", "Should remain unchanged");
}
#[test]
fn test_apply_formatting_options_no_options() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: None,
insert_final_newline: None,
trim_final_newlines: None,
};
let content = "hello \nworld\n\n\n";
let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
assert_eq!(result, content, "Content should be unchanged when no options set");
}
#[test]
fn test_apply_formatting_options_empty_content() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
};
let result = RumdlLanguageServer::apply_formatting_options("".to_string(), &options);
assert_eq!(result, "");
let result = RumdlLanguageServer::apply_formatting_options("\n\n\n".to_string(), &options);
assert_eq!(result, "\n");
}
#[test]
fn test_apply_formatting_options_multiline_content() {
let options = FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
};
let content = "# Heading \n\nParagraph \n- List item \n\n\n";
let result = RumdlLanguageServer::apply_formatting_options(content.to_string(), &options);
assert_eq!(result, "# Heading\n\nParagraph\n- List item\n");
}
#[test]
fn test_code_action_kind_filtering() {
let matches = |action_kind: &str, requested: &str| -> bool { action_kind.starts_with(requested) };
assert!(matches("source.fixAll.rumdl", "source.fixAll"));
assert!(matches("source.fixAll.rumdl", "source.fixAll.rumdl"));
assert!(matches("source.fixAll.rumdl", "source"));
assert!(matches("quickfix", "quickfix"));
assert!(!matches("source.fixAll.rumdl", "quickfix"));
assert!(!matches("quickfix", "source.fixAll"));
assert!(!matches("source.fixAll", "source.fixAll.rumdl"));
}
#[test]
fn test_code_action_kind_filter_with_empty_array() {
let filter_actions = |kinds: Option<Vec<&str>>| -> bool {
if let Some(ref k) = kinds
&& !k.is_empty()
{
false
} else {
true
}
};
assert!(filter_actions(None));
assert!(filter_actions(Some(vec![])));
assert!(!filter_actions(Some(vec!["source.fixAll"])));
}
#[test]
fn test_code_action_kind_constants() {
let fix_all_rumdl = CodeActionKind::new("source.fixAll.rumdl");
assert_eq!(fix_all_rumdl.as_str(), "source.fixAll.rumdl");
assert!(
fix_all_rumdl
.as_str()
.starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str())
);
}
#[test]
fn test_detect_code_fence_language_position_basic() {
let text = "```\ncode\n```";
let pos = Position { line: 0, character: 3 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 3);
assert_eq!(current_text, "");
}
#[test]
fn test_detect_code_fence_language_position_partial_lang() {
let text = "```py\ncode\n```";
let pos = Position { line: 0, character: 5 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 3);
assert_eq!(current_text, "py");
}
#[test]
fn test_detect_code_fence_language_position_full_lang() {
let text = "```python\ncode\n```";
let pos = Position { line: 0, character: 9 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 3);
assert_eq!(current_text, "python");
}
#[test]
fn test_detect_code_fence_language_position_tilde_fence() {
let text = "~~~rust\ncode\n~~~";
let pos = Position { line: 0, character: 7 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 3);
assert_eq!(current_text, "rust");
}
#[test]
fn test_detect_code_fence_language_position_indented() {
let text = " ```js\ncode\n ```";
let pos = Position { line: 0, character: 7 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 5); assert_eq!(current_text, "js");
}
#[test]
fn test_detect_code_fence_language_position_not_fence_line() {
let text = "```python\ncode\n```";
let pos = Position { line: 1, character: 2 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_code_fence_language_position_closing_fence() {
let text = "```python\ncode\n```";
let pos = Position { line: 2, character: 3 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none(), "Should not offer completion on closing fence");
}
#[test]
fn test_detect_code_fence_language_position_extended_fence() {
let text = "````python\ncode\n````";
let pos = Position { line: 0, character: 10 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 4); assert_eq!(current_text, "python");
}
#[test]
fn test_detect_code_fence_language_position_extended_fence_5_backticks() {
let text = "`````js\ncode\n`````";
let pos = Position { line: 0, character: 7 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 5);
assert_eq!(current_text, "js");
}
#[test]
fn test_detect_code_fence_language_position_nested_code_blocks() {
let text = "````markdown\n```python\ncode\n```\n````";
let pos = Position { line: 0, character: 12 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some());
let (_, current_text) = result.unwrap();
assert_eq!(current_text, "markdown");
}
#[test]
fn test_detect_code_fence_language_position_extended_closing_fence() {
let text = "````python\ncode here\n````";
let pos = Position { line: 2, character: 4 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(
result.is_none(),
"Should not offer completion on extended closing fence"
);
}
#[test]
fn test_detect_code_fence_language_position_cursor_before_fence() {
let text = "```python\ncode\n```";
let pos = Position { line: 0, character: 2 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_code_fence_language_position_with_info_string() {
let text = "```python filename.py\ncode\n```";
let pos = Position { line: 0, character: 15 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_code_fence_language_position_regular_text() {
let text = "# Heading\n\nSome text.";
let pos = Position { line: 0, character: 5 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_code_fence_language_position_non_ascii_language() {
let text = "```résumé";
let pos = Position { line: 0, character: 9 }; let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_some(), "should detect fence language with non-ASCII text");
let (start_col, current_text) = result.unwrap();
assert_eq!(start_col, 3); assert_eq!(current_text, "résumé");
}
#[test]
fn test_detect_code_fence_language_position_inline_code() {
let text = "Use `code` here.";
let pos = Position { line: 0, character: 5 };
let result = RumdlLanguageServer::detect_code_fence_language_position(text, pos);
assert!(result.is_none());
}
#[tokio::test]
async fn test_completion_provides_language_items() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(&test_file, "```py\ncode\n```").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(&test_file).unwrap();
let content = "```py\ncode\n```".to_string();
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.clone(),
version: Some(1),
from_disk: false,
},
);
let items = server
.get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
.await;
assert!(!items.is_empty(), "Should return completion items");
let has_python = items.iter().any(|item| item.label.to_lowercase() == "python");
assert!(has_python, "Should include 'python' as a completion item");
}
#[tokio::test]
async fn test_completion_filters_by_prefix() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
std::fs::write(&test_file, "```ru\ncode\n```").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(&test_file).unwrap();
let items = server
.get_language_completions(&uri, "ru", 3, Position { line: 0, character: 5 })
.await;
for item in &items {
assert!(
item.label.to_lowercase().starts_with("ru"),
"Completion '{}' should start with 'ru'",
item.label
);
}
let has_rust = items.iter().any(|item| item.label.to_lowercase() == "rust");
let has_ruby = items.iter().any(|item| item.label.to_lowercase() == "ruby");
assert!(has_rust, "Should include 'rust'");
assert!(has_ruby, "Should include 'ruby'");
}
#[tokio::test]
async fn test_completion_empty_prefix_returns_all() {
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
std::fs::write(&test_file, "```\ncode\n```").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(&test_file).unwrap();
let items = server
.get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
.await;
assert!(items.len() >= 10, "Should return multiple language options");
assert!(items.len() <= 100, "Should be limited to 100 items");
}
#[tokio::test]
async fn test_completion_respects_md040_allowed_languages() {
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(&test_file, "```\ncode\n```").unwrap();
let config_file = temp_dir.path().join(".rumdl.toml");
fs::write(
&config_file,
r#"
[MD040]
allowed-languages = ["Python", "Rust", "Go"]
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(temp_dir.path().to_path_buf());
}
let uri = Url::from_file_path(&test_file).unwrap();
let items = server
.get_language_completions(&uri, "", 3, Position { line: 0, character: 3 })
.await;
for item in &items {
let label_lower = item.label.to_lowercase();
let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
let is_allowed = detail.contains("python") || detail.contains("rust") || detail.contains("go");
assert!(
is_allowed,
"Completion '{label_lower}' (detail: '{detail}') should be for Python, Rust, or Go"
);
}
}
#[tokio::test]
async fn test_completion_respects_md040_disallowed_languages() {
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
fs::write(&test_file, "```py\ncode\n```").unwrap();
let config_file = temp_dir.path().join(".rumdl.toml");
fs::write(
&config_file,
r#"
[MD040]
disallowed-languages = ["Python"]
"#,
)
.unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(temp_dir.path().to_path_buf());
}
let uri = Url::from_file_path(&test_file).unwrap();
let items = server
.get_language_completions(&uri, "py", 3, Position { line: 0, character: 5 })
.await;
for item in &items {
let detail = item.detail.as_ref().map(|d| d.to_lowercase()).unwrap_or_default();
assert!(
!detail.contains("python"),
"Completion '{}' should not include Python (disallowed)",
item.label
);
}
}
#[test]
fn test_is_closing_fence_basic() {
let lines = vec!["```python"];
assert!(
RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"After opening fence, next fence is closing"
);
}
#[test]
fn test_is_closing_fence_with_content() {
let lines = vec!["```python", "some code"];
assert!(
RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"After opening fence with content, next fence is closing"
);
}
#[test]
fn test_is_closing_fence_no_prior_fence() {
let lines: Vec<&str> = vec!["# Hello", "Some text"];
assert!(
!RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"With no prior fence, next fence is opening"
);
}
#[test]
fn test_is_closing_fence_already_closed() {
let lines = vec!["```python", "some code", "```"];
assert!(
!RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"After closed code block, next fence is opening"
);
}
#[test]
fn test_is_closing_fence_extended() {
let lines = vec!["````python", "some code"];
assert!(
!RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"3 backticks cannot close 4-backtick fence"
);
assert!(
RumdlLanguageServer::is_closing_fence(&lines, '`', 4),
"4 backticks can close 4-backtick fence"
);
assert!(
RumdlLanguageServer::is_closing_fence(&lines, '`', 5),
"5 backticks can close 4-backtick fence"
);
}
#[test]
fn test_is_closing_fence_mixed_chars() {
let lines = vec!["~~~python", "some code"];
assert!(
!RumdlLanguageServer::is_closing_fence(&lines, '`', 3),
"Backtick fence cannot close tilde fence"
);
assert!(
RumdlLanguageServer::is_closing_fence(&lines, '~', 3),
"Tilde fence can close tilde fence"
);
}
#[tokio::test]
async fn test_completion_method_integration() {
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
let content = "# Hello\n\n```py\nprint('hi')\n```";
fs::write(&test_file, content).unwrap();
let server = create_test_server();
let uri = Url::from_file_path(&test_file).unwrap();
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line: 2, character: 5 }, },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = server.completion(params).await.unwrap();
assert!(result.is_some(), "Completion should return items");
if let Some(CompletionResponse::Array(items)) = result {
assert!(!items.is_empty(), "Should have completion items");
let has_python = items.iter().any(|i| i.label.to_lowercase() == "python");
assert!(has_python, "Should include python as completion");
} else {
panic!("Expected CompletionResponse::Array");
}
}
#[tokio::test]
async fn test_completion_not_triggered_on_closing_fence() {
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
let content = "```python\nprint('hi')\n```";
fs::write(&test_file, content).unwrap();
let server = create_test_server();
let uri = Url::from_file_path(&test_file).unwrap();
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line: 2, character: 3 }, },
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = server.completion(params).await.unwrap();
assert!(result.is_none(), "Should NOT offer completion on closing fence");
}
#[tokio::test]
async fn test_completion_graceful_when_document_not_found() {
let server = create_test_server();
let uri = Url::parse("file:///nonexistent/path/test.md").unwrap();
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position { line: 0, character: 3 },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = server.completion(params).await;
assert!(result.is_ok(), "Completion should not error for missing document");
assert!(result.unwrap().is_none(), "Should return None for missing document");
}
#[test]
fn test_detect_link_target_file_path_empty() {
let text = "See [text](";
let pos = Position { line: 0, character: 11 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "");
assert_eq!(info.path_start_col, 11); assert!(info.anchor.is_none());
}
#[test]
fn test_detect_link_target_file_path_partial() {
let text = "See [text](docs/guide";
let pos = Position { line: 0, character: 21 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "docs/guide");
assert_eq!(info.path_start_col, 11);
assert!(info.anchor.is_none());
}
#[test]
fn test_detect_link_target_anchor_empty() {
let text = "See [text](guide.md#";
let pos = Position { line: 0, character: 20 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "guide.md");
assert!(info.anchor.is_some());
let (partial, start_col) = info.anchor.unwrap();
assert_eq!(partial, "");
assert_eq!(start_col, 20); }
#[test]
fn test_detect_link_target_anchor_partial() {
let text = "See [text](guide.md#install";
let pos = Position { line: 0, character: 27 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "guide.md");
let (partial, start_col) = info.anchor.unwrap();
assert_eq!(partial, "install");
assert_eq!(start_col, 20);
}
#[test]
fn test_detect_link_target_anchor_same_file() {
let text = "[text](#sec";
let pos = Position { line: 0, character: 11 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "");
let (partial, _start_col) = info.anchor.unwrap();
assert_eq!(partial, "sec");
}
#[test]
fn test_detect_link_target_closed_paren_no_completion() {
let text = "See [text](guide.md) more";
let pos = Position { line: 0, character: 21 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_none(), "Should not complete after a closed link");
}
#[test]
fn test_detect_link_target_no_link_syntax() {
let text = "Just plain text here";
let pos = Position { line: 0, character: 10 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_link_target_code_span_skipped() {
let text = "Use `[text](path` for links";
let pos = Position { line: 0, character: 16 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_none(), "Should not complete inside a code span");
}
#[test]
fn test_detect_link_target_image_link() {
let text = ";
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "imgs/");
}
#[test]
fn test_detect_link_target_multiple_links_on_line() {
let text = "[first](a.md) and [second](b.md";
let pos = Position { line: 0, character: 31 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "b.md");
}
#[test]
fn test_detect_link_target_non_ascii_link_text() {
let text = "[résumé](";
let pos = Position { line: 0, character: 9 }; let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some(), "should detect link in non-ASCII context");
let info = result.unwrap();
assert_eq!(info.file_path, "");
assert_eq!(info.path_start_col, 9);
assert!(info.anchor.is_none());
}
#[test]
fn test_detect_link_target_non_ascii_with_path() {
let text = "[café](docs/";
let pos = Position { line: 0, character: 12 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "docs/");
assert_eq!(info.path_start_col, 7); }
#[test]
fn test_detect_link_target_out_of_bounds_position() {
let text = "short";
let pos = Position {
line: 0,
character: 100,
};
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_none());
}
#[test]
fn test_detect_link_target_out_of_bounds_line() {
let text = "single line";
let pos = Position { line: 5, character: 0 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_none());
}
#[tokio::test]
async fn test_get_file_completions_returns_workspace_files() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("current.md");
let other = temp_dir.path().join("other.md");
let sub_dir = temp_dir.path().join("docs");
fs::create_dir(&sub_dir).unwrap();
let sub_file = sub_dir.join("guide.md");
fs::write(¤t, "# Current").unwrap();
fs::write(&other, "# Other").unwrap();
fs::write(&sub_file, "# Guide").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
let mut fi = FileIndex::default();
fi.headings.push(HeadingIndex {
text: "Current".to_string(),
auto_anchor: "current".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(current.clone(), fi);
let mut fi2 = FileIndex::default();
fi2.headings.push(HeadingIndex {
text: "Other".to_string(),
auto_anchor: "other".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(other.clone(), fi2);
let mut fi3 = FileIndex::default();
fi3.headings.push(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(sub_file.clone(), fi3);
}
let items = server
.get_file_completions(&uri, "", 10, Position { line: 0, character: 10 })
.await;
assert_eq!(items.len(), 2, "Should return 2 files (excluding current)");
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"other.md"), "Should include other.md");
assert!(labels.contains(&"docs/guide.md"), "Should include docs/guide.md");
assert!(!labels.contains(&"current.md"), "Should exclude current.md");
}
#[tokio::test]
async fn test_get_file_completions_filters_by_prefix() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("current.md");
let docs_dir = temp_dir.path().join("docs");
fs::create_dir(&docs_dir).unwrap();
let guide = docs_dir.join("guide.md");
let ref_doc = docs_dir.join("reference.md");
fs::write(¤t, "").unwrap();
fs::write(&guide, "").unwrap();
fs::write(&ref_doc, "").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
index.insert_file(current.clone(), FileIndex::default());
index.insert_file(guide.clone(), FileIndex::default());
index.insert_file(ref_doc.clone(), FileIndex::default());
}
let items = server
.get_file_completions(&uri, "docs/g", 10, Position { line: 0, character: 16 })
.await;
assert_eq!(items.len(), 1, "Should return only docs/guide.md");
assert_eq!(items[0].label, "docs/guide.md");
}
#[tokio::test]
async fn test_get_anchor_completions_returns_headings() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("index.md");
let target = temp_dir.path().join("guide.md");
fs::write(¤t, "").unwrap();
fs::write(&target, "# Installation\n\n## Configuration\n\n## Troubleshooting").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
index.insert_file(current.clone(), FileIndex::default());
let mut fi = FileIndex::default();
fi.headings = vec![
HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
},
HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 3,
is_setext: false,
},
HeadingIndex {
text: "Troubleshooting".to_string(),
auto_anchor: "troubleshooting".to_string(),
custom_anchor: None,
line: 5,
is_setext: false,
},
];
index.insert_file(target.clone(), fi);
}
let items = server
.get_anchor_completions(&uri, "guide.md", "", 27, Position { line: 0, character: 27 })
.await;
assert_eq!(items.len(), 3, "Should return all 3 headings");
assert_eq!(items[0].insert_text.as_deref(), Some("installation"));
assert_eq!(items[1].insert_text.as_deref(), Some("configuration"));
assert_eq!(items[2].insert_text.as_deref(), Some("troubleshooting"));
}
#[tokio::test]
async fn test_get_anchor_completions_filters_by_prefix() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("index.md");
let target = temp_dir.path().join("guide.md");
fs::write(¤t, "").unwrap();
fs::write(&target, "").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
index.insert_file(current.clone(), FileIndex::default());
let mut fi = FileIndex::default();
fi.headings = vec![
HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
},
HeadingIndex {
text: "Introduction".to_string(),
auto_anchor: "introduction".to_string(),
custom_anchor: None,
line: 2,
is_setext: false,
},
HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 3,
is_setext: false,
},
];
index.insert_file(target.clone(), fi);
}
let items = server
.get_anchor_completions(&uri, "guide.md", "in", 27, Position { line: 0, character: 27 })
.await;
assert_eq!(items.len(), 2, "Should return installation and introduction");
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"Installation"));
assert!(labels.contains(&"Introduction"));
}
#[tokio::test]
async fn test_get_anchor_completions_uses_custom_anchor() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("index.md");
let target = temp_dir.path().join("guide.md");
fs::write(¤t, "").unwrap();
fs::write(&target, "").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
index.insert_file(current.clone(), FileIndex::default());
let mut fi = FileIndex::default();
fi.headings = vec![HeadingIndex {
text: "Getting Started".to_string(),
auto_anchor: "getting-started".to_string(),
custom_anchor: Some("start".to_string()),
line: 1,
is_setext: false,
}];
index.insert_file(target.clone(), fi);
}
let items = server
.get_anchor_completions(&uri, "guide.md", "", 10, Position { line: 0, character: 10 })
.await;
assert_eq!(items.len(), 1);
assert_eq!(items[0].insert_text.as_deref(), Some("start"));
assert_eq!(items[0].label, "Getting Started");
assert_eq!(items[0].detail.as_deref(), Some("#start"));
}
#[tokio::test]
async fn test_get_anchor_completions_empty_file_path_uses_current() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let current = temp_dir.path().join("page.md");
fs::write(¤t, "# Section One\n\n# Section Two").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
{
use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex};
let mut index = server.workspace_index.write().await;
*index = WorkspaceIndex::new();
let mut fi = FileIndex::default();
fi.headings = vec![
HeadingIndex {
text: "Section One".to_string(),
auto_anchor: "section-one".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
},
HeadingIndex {
text: "Section Two".to_string(),
auto_anchor: "section-two".to_string(),
custom_anchor: None,
line: 3,
is_setext: false,
},
];
index.insert_file(current.clone(), fi);
}
let items = server
.get_anchor_completions(&uri, "", "", 8, Position { line: 0, character: 8 })
.await;
assert_eq!(items.len(), 2);
let insert_texts: Vec<&str> = items.iter().map(|i| i.insert_text.as_deref().unwrap_or("")).collect();
assert!(insert_texts.contains(&"section-one"));
assert!(insert_texts.contains(&"section-two"));
}
#[tokio::test]
async fn test_get_anchor_completions_unknown_file_returns_empty() {
let temp_dir = tempfile::tempdir().unwrap();
let current = temp_dir.path().join("index.md");
std::fs::write(¤t, "").unwrap();
let server = create_test_server();
let uri = Url::from_file_path(¤t).unwrap();
let items = server
.get_anchor_completions(&uri, "nonexistent.md", "", 10, Position { line: 0, character: 10 })
.await;
assert!(items.is_empty(), "Unknown file should return no completions");
}
#[test]
fn test_detect_link_target_relative_parent_path() {
let text = "See [link](../other/file";
let pos = Position { line: 0, character: 24 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "../other/file");
assert!(info.anchor.is_none());
}
#[test]
fn test_detect_link_target_path_and_anchor() {
let text = "See [link](../dir/file.md#section";
let pos = Position { line: 0, character: 33 };
let result = RumdlLanguageServer::detect_link_target_position(text, pos);
assert!(result.is_some());
let info = result.unwrap();
assert_eq!(info.file_path, "../dir/file.md");
let (partial, _) = info.anchor.unwrap();
assert_eq!(partial, "section");
}
#[tokio::test]
async fn test_link_completions_disabled_returns_none() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let test_file = temp_dir.path().join("test.md");
let content = "See [text](";
fs::write(&test_file, content).unwrap();
let server = create_test_server();
server.config.write().await.enable_link_completions = false;
let uri = Url::from_file_path(&test_file).unwrap();
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let params = CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri },
position: Position { line: 0, character: 11 },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
};
let result = server.completion(params).await.unwrap();
assert!(result.is_none(), "Link completions should be suppressed when disabled");
}
#[tokio::test]
async fn test_lsp_md013_semantic_line_breaks_crlf() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let pyproject_path = temp_dir.path().join("pyproject.toml");
std::fs::write(
&pyproject_path,
r#"
[tool.rumdl.MD013]
line-length = 80
reflow = true
reflow-mode = "semantic-line-breaks"
"#,
)
.expect("Failed to write pyproject.toml");
let test_md_path = temp_dir.path().join("test.md");
let content_crlf = "# Title\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit.\r\nNullam vehicula commodo lobortis.\r\nDonec a venenatis lorem.\r\n";
std::fs::write(&test_md_path, content_crlf).expect("Failed to write test.md");
let canonical_test_path = test_md_path.canonicalize().unwrap_or_else(|_| test_md_path.clone());
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap_or_else(|_| temp_dir.path().to_path_buf());
server.workspace_roots.write().await.push(canonical_temp);
let uri = Url::from_file_path(&canonical_test_path).unwrap();
let diagnostics = server.lint_document(&uri, content_crlf, true).await.unwrap();
let md013_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.code
.as_ref()
.map(|c| matches!(c, NumberOrString::String(s) if s == "MD013"))
.unwrap_or(false)
})
.collect();
assert!(
md013_diagnostics.is_empty(),
"LSP should produce no MD013 warnings for properly formatted semantic-line-break content \
with CRLF line endings, but found {} warnings: {:?}",
md013_diagnostics.len(),
md013_diagnostics
.iter()
.map(|d| format!("line {}: {}", d.range.start.line, d.message))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_lsp_md013_semantic_line_breaks_crlf_still_warns_when_needed() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let pyproject_path = temp_dir.path().join("pyproject.toml");
std::fs::write(
&pyproject_path,
r#"
[tool.rumdl.MD013]
line-length = 80
reflow = true
reflow-mode = "semantic-line-breaks"
"#,
)
.expect("Failed to write pyproject.toml");
let test_md_path = temp_dir.path().join("test.md");
let content_crlf =
"# Title\r\n\r\nLorem ipsum dolor sit amet. Consectetur adipiscing elit. Nullam vehicula commodo lobortis.\r\n";
std::fs::write(&test_md_path, content_crlf).expect("Failed to write test.md");
let canonical_test_path = test_md_path.canonicalize().unwrap_or_else(|_| test_md_path.clone());
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap_or_else(|_| temp_dir.path().to_path_buf());
server.workspace_roots.write().await.push(canonical_temp);
let uri = Url::from_file_path(&canonical_test_path).unwrap();
let diagnostics = server.lint_document(&uri, content_crlf, true).await.unwrap();
let md013_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.code
.as_ref()
.map(|c| matches!(c, NumberOrString::String(s) if s == "MD013"))
.unwrap_or(false)
})
.collect();
assert!(
!md013_diagnostics.is_empty(),
"LSP should produce MD013 warnings for improperly formatted semantic-line-break CRLF content"
);
}
#[tokio::test]
async fn test_lsp_md013_semantic_line_breaks_config_parity() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let pyproject_path = temp_dir.path().join("pyproject.toml");
std::fs::write(
&pyproject_path,
r#"
[tool.rumdl.MD013]
line-length = 80
reflow = true
reflow-mode = "semantic-line-breaks"
"#,
)
.expect("Failed to write pyproject.toml");
let test_md_path = temp_dir.path().join("test.md");
let content = "# Title\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.\nNullam vehicula commodo lobortis.\nDonec a venenatis lorem.\n";
std::fs::write(&test_md_path, content).expect("Failed to write test.md");
let uri = Url::from_file_path(&test_md_path).unwrap();
let config_path_str = pyproject_path.to_str().unwrap();
let sourced = RumdlLanguageServer::load_config_for_lsp(Some(config_path_str)).expect("Should load config");
let file_config: crate::config::Config = sourced.into_validated_unchecked().into();
let md013_rule_config = file_config.rules.get("MD013");
assert!(
md013_rule_config.is_some(),
"MD013 config should be present in loaded config"
);
let md013_values = &md013_rule_config.unwrap().values;
assert!(
md013_values.get("reflow-mode").is_some() || md013_values.get("reflow_mode").is_some(),
"reflow-mode should be in MD013 config values, got: {:?}",
md013_values.keys().collect::<Vec<_>>()
);
*server.rumdl_config.write().await = file_config;
let diagnostics = server.lint_document(&uri, content, true).await.unwrap();
let md013_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.code
.as_ref()
.map(|c| matches!(c, NumberOrString::String(s) if s == "MD013"))
.unwrap_or(false)
})
.collect();
assert!(
md013_diagnostics.is_empty(),
"LSP should produce no MD013 warnings for properly formatted semantic-line-break content, \
but found {} warnings: {:?}",
md013_diagnostics.len(),
md013_diagnostics
.iter()
.map(|d| format!("line {}: {}", d.range.start.line, d.message))
.collect::<Vec<_>>()
);
let config_path_str2 = pyproject_path.to_str().unwrap();
let sourced2 = crate::config::SourcedConfig::load_with_discovery(Some(config_path_str2), None, false)
.expect("Should load config");
let cli_config: crate::config::Config = sourced2.into_validated_unchecked().into();
let all_rules = crate::rules::all_rules(&cli_config);
let filtered_rules = crate::rules::filter_rules(&all_rules, &cli_config.global);
let cli_warnings = crate::lint(
content,
&filtered_rules,
false,
crate::config::MarkdownFlavor::Standard,
None,
Some(&cli_config),
)
.expect("CLI lint should succeed");
let cli_md013: Vec<_> = cli_warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD013"))
.collect();
assert!(cli_md013.is_empty(), "CLI should produce no MD013 warnings either");
}
#[tokio::test]
async fn test_lsp_md013_resolve_config_for_file_path() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let pyproject_path = temp_dir.path().join("pyproject.toml");
std::fs::write(
&pyproject_path,
r#"
[tool.rumdl]
[tool.rumdl.MD013]
line-length = 80
reflow = true
reflow-mode = "semantic-line-breaks"
"#,
)
.expect("Failed to write pyproject.toml");
let test_md_path = temp_dir.path().join("test.md");
let content = "# Title\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.\nNullam vehicula commodo lobortis.\nDonec a venenatis lorem.\n";
std::fs::write(&test_md_path, content).expect("Failed to write test.md");
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap_or_else(|_| temp_dir.path().to_path_buf());
server.workspace_roots.write().await.push(canonical_temp.clone());
let canonical_test_path = test_md_path.canonicalize().unwrap_or_else(|_| test_md_path.clone());
let resolved_config = server.resolve_config_for_file(&canonical_test_path).await;
let md013_rule_config = resolved_config.rules.get("MD013");
assert!(
md013_rule_config.is_some(),
"MD013 config should be present after resolve_config_for_file. Rules: {:?}",
resolved_config.rules.keys().collect::<Vec<_>>()
);
let md013_values = &md013_rule_config.unwrap().values;
let has_reflow_mode = md013_values.get("reflow-mode").is_some() || md013_values.get("reflow_mode").is_some();
assert!(
has_reflow_mode,
"reflow-mode should be in MD013 config values after resolve_config_for_file, got: {:?}",
md013_values.keys().collect::<Vec<_>>()
);
let has_reflow = md013_values.get("reflow").is_some();
assert!(
has_reflow,
"reflow should be in MD013 config values after resolve_config_for_file, got: {:?}",
md013_values.keys().collect::<Vec<_>>()
);
let all_rules = crate::rules::all_rules(&resolved_config);
let filtered_rules = crate::rules::filter_rules(&all_rules, &resolved_config.global);
let warnings = crate::lint(
content,
&filtered_rules,
false,
crate::config::MarkdownFlavor::Standard,
None,
Some(&resolved_config),
)
.expect("Lint should succeed");
let md013_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD013"))
.collect();
assert!(
md013_warnings.is_empty(),
"Should produce no MD013 warnings for semantic-line-break content via resolve_config_for_file path, \
but found {} warnings: {:?}",
md013_warnings.len(),
md013_warnings
.iter()
.map(|w| format!("line {}: {} - {}", w.line, w.message, w.message))
.collect::<Vec<_>>()
);
let uri = Url::from_file_path(&canonical_test_path).unwrap();
let diagnostics = server.lint_document(&uri, content, true).await.unwrap();
let md013_diags: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.code
.as_ref()
.map(|c| matches!(c, NumberOrString::String(s) if s == "MD013"))
.unwrap_or(false)
})
.collect();
assert!(
md013_diags.is_empty(),
"lint_document should produce no MD013 diagnostics for semantic-line-break content, \
but found {} diagnostics: {:?}",
md013_diags.len(),
md013_diags
.iter()
.map(|d| format!("line {}: {}", d.range.start.line, d.message))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn test_lsp_md007_formatting_respects_indent_config() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let config_path = temp_dir.path().join(".rumdl.toml");
std::fs::write(
&config_path,
r#"
[ul-indent]
indent = 4
style = "fixed"
"#,
)
.expect("Failed to write .rumdl.toml");
let test_md_path = temp_dir.path().join("test.md");
let content = "# Test\n\n- Bullet item\n - Nested bullet\n";
std::fs::write(&test_md_path, content).expect("Failed to write test.md");
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap_or_else(|_| temp_dir.path().to_path_buf());
server.workspace_roots.write().await.push(canonical_temp.clone());
let canonical_test_path = test_md_path.canonicalize().unwrap_or_else(|_| test_md_path.clone());
let resolved_config = server.resolve_config_for_file(&canonical_test_path).await;
let md007_config = resolved_config.rules.get("MD007");
assert!(
md007_config.is_some(),
"MD007 config should be present. Rules: {:?}",
resolved_config.rules.keys().collect::<Vec<_>>()
);
let md007_values = &md007_config.unwrap().values;
let indent_value = md007_values.get("indent").map(|v| v.as_integer().unwrap_or(0));
assert_eq!(indent_value, Some(4), "MD007 indent should be 4, got: {md007_values:?}",);
let all_rules = crate::rules::all_rules(&resolved_config);
let filtered_rules = crate::rules::filter_rules(&all_rules, &resolved_config.global);
let warnings = crate::lint(
content,
&filtered_rules,
false,
crate::config::MarkdownFlavor::Standard,
None,
Some(&resolved_config),
)
.expect("Lint should succeed");
let md007_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD007"))
.collect();
assert_eq!(
md007_warnings.len(),
1,
"Should find exactly 1 MD007 warning for 2-space indent, found: {:?}",
md007_warnings.iter().map(|w| &w.message).collect::<Vec<_>>()
);
assert!(
md007_warnings[0].message.contains("Expected 4 spaces"),
"Warning should mention 4 spaces, got: {}",
md007_warnings[0].message
);
assert!(md007_warnings[0].fix.is_some(), "MD007 warning should have a fix");
let fix = md007_warnings[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement, " ",
"Fix replacement should be 4 spaces, got: {:?}",
fix.replacement
);
let uri = Url::from_file_path(&canonical_test_path).unwrap();
let entry = DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let params = DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 4,
insert_spaces: true,
properties: HashMap::new(),
trim_trailing_whitespace: Some(true),
insert_final_newline: Some(true),
trim_final_newlines: Some(true),
},
work_done_progress_params: WorkDoneProgressParams::default(),
};
let result = server.formatting(params).await.unwrap();
assert!(result.is_some(), "Formatting should return edits");
let edits = result.unwrap();
assert!(!edits.is_empty(), "Should have at least one edit");
let formatted_text = &edits[0].new_text;
assert!(
formatted_text.contains(" - Nested bullet"),
"Formatted text should have 4-space indent, got:\n{formatted_text}",
);
assert!(
!formatted_text.contains("\n - Nested"),
"Formatted text should NOT have 2-space indent, got:\n{formatted_text}",
);
}
#[tokio::test]
async fn test_lsp_md007_code_action_fix_all_respects_indent_config() {
use tempfile::tempdir;
let server = create_test_server();
let temp_dir = tempdir().expect("Failed to create temp dir");
let config_path = temp_dir.path().join(".rumdl.toml");
std::fs::write(
&config_path,
r#"
[ul-indent]
indent = 4
style = "fixed"
"#,
)
.expect("Failed to write .rumdl.toml");
let content = "- Bullet item\n - Nested bullet\n 1. Ordered child\n - Bullet under ordered\n";
let test_md_path = temp_dir.path().join("test.md");
std::fs::write(&test_md_path, content).expect("Failed to write test.md");
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap_or_else(|_| temp_dir.path().to_path_buf());
server.workspace_roots.write().await.push(canonical_temp.clone());
let canonical_test_path = test_md_path.canonicalize().unwrap_or_else(|_| test_md_path.clone());
let uri = Url::from_file_path(&canonical_test_path).unwrap();
let entry = DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
};
server.documents.write().await.insert(uri.clone(), entry);
let range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 3, character: 26 },
};
let actions = server.get_code_actions(&uri, content, range).await.unwrap();
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"source.fixAll.rumdl action should be created"
);
let fix_all = &fix_all_actions[0];
let edit = fix_all.edit.as_ref().expect("fixAll action should have an edit");
let changes = edit.changes.as_ref().expect("edit should have changes");
let text_edits = changes.get(&uri).expect("changes should include our file");
let fixed_text = &text_edits[0].new_text;
assert!(
fixed_text.contains("\n - Nested bullet"),
"source.fixAll should produce 4-space indent, got:\n{fixed_text}",
);
assert!(
!fixed_text.contains("\n - Nested"),
"source.fixAll should NOT have 2-space indent, got:\n{fixed_text}",
);
}
#[tokio::test]
async fn test_goto_definition_file_path_only() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [the guide](guide.md) for details.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 20 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should return a definition location");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md"
);
assert_eq!(location.range.start.line, 0, "Should target line 0 (top of file)");
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_file_with_anchor() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test2/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [install](guide.md#installation) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Getting Started".to_string(),
auto_anchor: "getting-started".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 10,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 25,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 18 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should return a definition location for file+anchor");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md"
);
assert_eq!(location.range.start.line, 9, "Should target the Installation heading");
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_same_file_anchor() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-test3/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "# Title\n\nSee [below](#configuration) for config.\n\n## Configuration\n\nSettings here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Title".to_string(),
auto_anchor: "title".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 5,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 2, character: 16 };
let result = server.handle_goto_definition(&uri, position).await;
assert!(result.is_some(), "Should return a definition for same-file anchor");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(location.uri, uri, "Should point to the same file");
assert_eq!(location.range.start.line, 4, "Should target the Configuration heading");
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_cursor_not_on_link() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-test4/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "# Title\n\nJust some plain text here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 2, character: 5 };
let result = server.handle_goto_definition(&uri, position).await;
assert!(result.is_none(), "Should return None when cursor is not on a link");
}
#[tokio::test]
async fn test_find_references_heading_with_incoming_links() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test5/docs");
let target_file = docs_dir.join("guide.md");
let source_file_a = docs_dir.join("index.md");
let source_file_b = docs_dir.join("faq.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "# Installation\n\nHow to install.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut target_fi = FileIndex::default();
target_fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), target_fi);
let mut source_a_fi = FileIndex::default();
source_a_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "installation".to_string(),
line: 5,
column: 10,
});
index.insert_file(source_file_a.clone(), source_a_fi);
let mut source_b_fi = FileIndex::default();
source_b_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "installation".to_string(),
line: 3,
column: 15,
});
index.insert_file(source_file_b.clone(), source_b_fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&target_uri, position).await;
assert!(result.is_some(), "Should find references to the heading");
let locations = result.unwrap();
assert_eq!(locations.len(), 2, "Should find 2 references from two files");
let uris: Vec<_> = locations.iter().map(|l| l.uri.clone()).collect();
assert!(
uris.contains(&Url::from_file_path(&source_file_a).unwrap()),
"Should include reference from index.md"
);
assert!(
uris.contains(&Url::from_file_path(&source_file_b).unwrap()),
"Should include reference from faq.md"
);
let a_loc = locations
.iter()
.find(|l| l.uri == Url::from_file_path(&source_file_a).unwrap())
.unwrap();
assert_eq!(
a_loc.range.start.line, 4,
"Line 5 (1-indexed) should become 4 (0-indexed)"
);
assert_eq!(
a_loc.range.start.character, 9,
"Column 10 (1-indexed) should become 9 (0-indexed)"
);
}
#[tokio::test]
async fn test_find_references_heading_no_incoming_links() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-test6/docs/lonely.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "# Lonely Heading\n\nNo one links here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Lonely Heading".to_string(),
auto_anchor: "lonely-heading".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&uri, position).await;
assert!(result.is_none(), "Should return None when no references exist");
}
#[tokio::test]
async fn test_goto_definition_with_custom_anchor() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test7/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [install](guide.md#install) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: Some("install".to_string()),
line: 15,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 18 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should resolve custom anchor");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.range.start.line, 14,
"Should target the heading with the custom anchor"
);
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_anchor_not_found_falls_back_to_line_zero() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test8/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [x](guide.md#nonexistent) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Introduction".to_string(),
auto_anchor: "introduction".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 15 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should still return a location for unresolved anchor");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.range.start.line, 0,
"Should fall back to line 0 when anchor not found"
);
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_find_references_from_link_position() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test9/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let other_file = docs_dir.join("faq.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [guide](guide.md) for info.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut target_fi = FileIndex::default();
target_fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), target_fi);
let mut current_fi = FileIndex::default();
current_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "".to_string(),
line: 3,
column: 12,
});
index.insert_file(current_file.clone(), current_fi);
let mut other_fi = FileIndex::default();
other_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "".to_string(),
line: 7,
column: 5,
});
index.insert_file(other_file.clone(), other_fi);
}
let position = Position { line: 2, character: 16 };
let result = server.handle_references(¤t_uri, position).await;
assert!(result.is_some(), "Should find references when cursor is on a link");
let locations = result.unwrap();
assert_eq!(locations.len(), 2, "Should find both links to guide.md");
let uris: Vec<_> = locations.iter().map(|l| l.uri.clone()).collect();
assert!(uris.contains(&Url::from_file_path(¤t_file).unwrap()));
assert!(uris.contains(&Url::from_file_path(&other_file).unwrap()));
}
#[tokio::test]
async fn test_find_references_from_target_file_without_selecting_link() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test9b/docs");
let source_file = docs_dir.join("file-to-link-from.md");
let target_file = docs_dir.join("file-to-link-to.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let target_content = "---\ntitle: Heading\n---\n\nTarget file content.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut source_fi = FileIndex::default();
source_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "file-to-link-to.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 24,
});
index.insert_file(source_file.clone(), source_fi);
}
let position = Position { line: 0, character: 0 };
let result = server.handle_references(&target_uri, position).await;
assert!(
result.is_some(),
"Should find incoming file-level references even when cursor is not on a link"
);
let locations = result.unwrap();
assert_eq!(locations.len(), 1, "Should find one incoming link");
assert_eq!(
locations[0].uri,
Url::from_file_path(&source_file).unwrap(),
"Reference should point to the linking source file"
);
assert_eq!(locations[0].range.start.line, 4);
}
#[tokio::test]
async fn test_goto_definition_link_with_title() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test10/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [guide](guide.md \"The Guide\") for details.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 16 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should resolve link target even with title attribute");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md despite title in link"
);
assert_eq!(location.range.start.line, 0, "Should target line 0");
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_angle_bracket_link() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test11/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [guide](<guide.md>) for details.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 16 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should resolve angle-bracket link target");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md despite angle brackets"
);
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_find_references_includes_same_file_fragment_links() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-test12/docs/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "# Installation\n\nSee [above](#installation) for details.\n\nMore text here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&uri, position).await;
assert!(
result.is_some(),
"Should find same-file fragment references to the heading"
);
let locations = result.unwrap();
assert_eq!(locations.len(), 1, "Should find the same-file #installation link");
assert_eq!(locations[0].range.start.line, 2, "Reference should be on line 2");
}
#[tokio::test]
async fn test_goto_definition_external_url_returns_none() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-url-test/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "See [example](https://example.com) for details.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 18 };
let result = server.handle_goto_definition(&uri, position).await;
assert!(result.is_none(), "Should return None for external URLs");
}
#[tokio::test]
async fn test_goto_definition_mailto_returns_none() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-mailto-test/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Email [us](mailto:info@example.com) for help.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 15 };
let result = server.handle_goto_definition(&uri, position).await;
assert!(result.is_none(), "Should return None for mailto: links");
}
#[tokio::test]
async fn test_goto_definition_reference_link() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-ref-test/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [click here][guide] for info.\n\n[guide]: guide.md#install\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: Some("install".to_string()),
line: 10,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 8 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should resolve full reference link");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md"
);
assert_eq!(location.range.start.line, 9, "Should target the install heading");
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_collapsed_reference() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-collapsed-test/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\nSee [guide][] for info.\n\n[guide]: guide.md\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 7 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should resolve collapsed reference link");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(
location.uri,
Url::from_file_path(&target_file).unwrap(),
"Should point to guide.md"
);
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_reference_definition_line() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-refdef-test/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let content = "# Index\n\n[guide]: guide.md#install\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: Some("install".to_string()),
line: 10,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 5 };
let result = server.handle_goto_definition(¤t_uri, position).await;
assert!(result.is_some(), "Should navigate from reference definition line");
if let Some(GotoDefinitionResponse::Scalar(location)) = result {
assert_eq!(location.uri, Url::from_file_path(&target_file).unwrap(),);
assert_eq!(location.range.start.line, 9);
} else {
panic!("Expected Scalar response");
}
}
#[tokio::test]
async fn test_goto_definition_reference_link_external_url() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-ref-ext-test/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "See [example] for info.\n\n[example]: https://example.com\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 7 };
let result = server.handle_goto_definition(&uri, position).await;
assert!(
result.is_none(),
"Should return None for reference link resolving to external URL"
);
}
#[tokio::test]
async fn test_fix_all_action_available_regardless_of_range() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Title\n\n\tTabbed text\n";
let narrow_range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 0 },
};
let actions = server.get_code_actions(&uri, text, narrow_range).await.unwrap();
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"fixAll should be created regardless of cursor position when document has fixable issues"
);
let full_range = Range {
start: Position { line: 0, character: 0 },
end: Position { line: 3, character: 0 },
};
let actions = server.get_code_actions(&uri, text, full_range).await.unwrap();
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"fixAll should be created with full document range"
);
}
#[tokio::test]
async fn test_fix_all_applies_all_document_fixes_regardless_of_range() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Title\n\n\tFirst issue\n\n\tSecond issue\n";
let partial_range = Range {
start: Position { line: 2, character: 0 },
end: Position { line: 2, character: 13 },
};
let actions = server.get_code_actions(&uri, text, partial_range).await.unwrap();
let fix_all_actions: Vec<_> = actions
.iter()
.filter(|a| a.kind.as_ref().is_some_and(|k| k.as_str() == "source.fixAll.rumdl"))
.collect();
assert!(
!fix_all_actions.is_empty(),
"fixAll should be created when the document has fixable issues"
);
let fix_all = &fix_all_actions[0];
let edit = fix_all.edit.as_ref().expect("fixAll should have an edit");
let changes = edit.changes.as_ref().expect("edit should have changes");
let text_edits = changes.get(&uri).expect("changes should include our file");
let fixed_text = &text_edits[0].new_text;
assert!(
!fixed_text.contains('\t'),
"fixAll should fix all tab issues in the document, not just those in range"
);
}
#[tokio::test]
async fn test_config_cache_stale_after_config_file_created() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let project = temp_dir.path().join("project");
fs::create_dir(&project).unwrap();
let test_file = project.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let config_before = server.resolve_config_for_file(&test_file).await;
let indent_before = crate::config::get_rule_config_value::<usize>(&config_before, "MD007", "indent");
assert!(
indent_before.is_none() || indent_before == Some(2),
"Before config file exists, MD007 indent should be default (2 or absent). Got: {indent_before:?}"
);
{
let cache = server.config_cache.read().await;
let entry = cache
.get(&project)
.expect("Cache should be populated after first resolve");
assert!(
entry.from_global_fallback,
"Cache entry should be from global fallback since no config file exists"
);
assert!(
entry.config_file.is_none(),
"Cache entry should have no config_file since it's a global fallback"
);
}
let config_path = project.join(".rumdl.toml");
fs::write(
&config_path,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
let config_after = server.resolve_config_for_file(&test_file).await;
let indent_after = crate::config::get_rule_config_value::<usize>(&config_after, "MD007", "indent");
assert_eq!(
indent_after,
Some(4),
"After .rumdl.toml is created, resolve_config_for_file should pick up the new config. \
Expected MD007 indent=4, got {indent_after:?}"
);
{
let cache = server.config_cache.read().await;
let entry = cache.get(&project).expect("Cache entry should exist after re-resolve");
assert!(
!entry.from_global_fallback,
"Cache entry should no longer be a global fallback after config file was discovered"
);
assert!(
entry.config_file.is_some(),
"Cache entry should reference the newly discovered config file"
);
}
}
#[tokio::test]
async fn test_config_cache_picks_up_new_config_after_manual_clear() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let project = temp_dir.path().join("project");
fs::create_dir(&project).unwrap();
let test_file = project.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let config_before = server.resolve_config_for_file(&test_file).await;
let indent_before = crate::config::get_rule_config_value::<usize>(&config_before, "MD007", "indent");
assert!(
indent_before.is_none() || indent_before == Some(2),
"Should start with default indent"
);
let config_path = project.join(".rumdl.toml");
fs::write(
&config_path,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
server.config_cache.write().await.clear();
let config_after = server.resolve_config_for_file(&test_file).await;
let indent_after = crate::config::get_rule_config_value::<usize>(&config_after, "MD007", "indent");
assert_eq!(
indent_after,
Some(4),
"After cache clear, should pick up new config with indent=4"
);
}
#[tokio::test]
async fn test_config_cache_retain_logic_misses_fallback_entries() {
use std::fs;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let project = temp_dir.path().join("project");
fs::create_dir(&project).unwrap();
let test_file = project.join("test.md");
fs::write(&test_file, "# Test\n").unwrap();
let server = create_test_server();
{
let mut roots = server.workspace_roots.write().await;
roots.push(project.clone());
}
let _ = server.resolve_config_for_file(&test_file).await;
let config_path = project.join(".rumdl.toml");
fs::write(
&config_path,
r#"
[MD007]
indent = 4
"#,
)
.unwrap();
{
let mut cache = server.config_cache.write().await;
cache.retain(|_, entry| {
if let Some(config_file) = &entry.config_file {
config_file != &config_path
} else {
true }
});
}
let cache = server.config_cache.read().await;
let entry = cache.get(&project);
assert!(
entry.is_some(),
"BUG: Fallback cache entry survives retain() because config_file is None"
);
assert!(
entry.unwrap().from_global_fallback,
"The surviving entry is the stale global fallback"
);
}
fn make_embedded_markdown_config() -> crate::code_block_tools::CodeBlockToolsConfig {
let lang = crate::code_block_tools::LanguageToolConfig {
enabled: true,
lint: vec!["rumdl".to_string()],
format: Vec::new(),
on_error: None,
};
let mut languages = std::collections::HashMap::new();
languages.insert("markdown".to_string(), lang);
crate::code_block_tools::CodeBlockToolsConfig {
enabled: true,
languages,
..Default::default()
}
}
#[tokio::test]
async fn test_lint_document_embedded_markdown_when_enabled() {
let server = create_test_server();
{
let mut cfg = server.rumdl_config.write().await;
cfg.code_block_tools = make_embedded_markdown_config();
}
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n```markdown\n# Hello \n```\n";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
let embedded_diags: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.range.start.line == 3
})
.collect();
assert!(
!embedded_diags.is_empty(),
"Expected embedded markdown diagnostics for trailing spaces inside code block, got none. All diagnostics: {diagnostics:?}"
);
}
#[tokio::test]
async fn test_lint_document_no_embedded_markdown_when_disabled() {
let server = create_test_server();
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n```markdown\n# Hello \n```\n";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
let embedded_diags: Vec<_> = diagnostics.iter().filter(|d| d.range.start.line == 3).collect();
assert!(
embedded_diags.is_empty(),
"Expected no embedded markdown diagnostics when code-block-tools is disabled, but got: {embedded_diags:?}"
);
}
#[tokio::test]
async fn test_lint_document_embedded_markdown_empty_block() {
let server = create_test_server();
{
let mut cfg = server.rumdl_config.write().await;
cfg.code_block_tools = make_embedded_markdown_config();
}
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n```markdown\n```\n";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
let embedded_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.range.start.line >= 2 && d.range.start.line <= 3)
.collect();
assert!(
embedded_diags.is_empty(),
"Expected no diagnostics from empty embedded markdown block, got: {embedded_diags:?}"
);
}
#[tokio::test]
async fn test_lint_document_multiple_embedded_blocks() {
let server = create_test_server();
{
let mut cfg = server.rumdl_config.write().await;
cfg.code_block_tools = make_embedded_markdown_config();
}
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n```markdown\n# One \n```\n\n```markdown\n# Two \n```\n";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
let block1_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.range.start.line == 3) .collect();
let block2_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.range.start.line == 7) .collect();
assert!(
!block1_diags.is_empty(),
"Expected diagnostics from first embedded block. All diagnostics: {diagnostics:?}"
);
assert!(
!block2_diags.is_empty(),
"Expected diagnostics from second embedded block. All diagnostics: {diagnostics:?}"
);
}
#[tokio::test]
async fn test_lint_document_embedded_markdown_md_alias() {
let server = create_test_server();
{
let mut cfg = server.rumdl_config.write().await;
let mut languages = std::collections::HashMap::new();
languages.insert(
"md".to_string(),
crate::code_block_tools::LanguageToolConfig {
enabled: true,
lint: vec!["rumdl".to_string()],
format: Vec::new(),
on_error: None,
},
);
cfg.code_block_tools = crate::code_block_tools::CodeBlockToolsConfig {
enabled: true,
languages,
..Default::default()
};
}
let uri = Url::parse("file:///test.md").unwrap();
let text = "# Test\n\n```md\n# Hello \n```\n";
let diagnostics = server.lint_document(&uri, text, true).await.unwrap();
let embedded_diags: Vec<_> = diagnostics.iter().filter(|d| d.range.start.line == 3).collect();
assert!(
!embedded_diags.is_empty(),
"Expected embedded markdown diagnostics for `md` alias. All diagnostics: {diagnostics:?}"
);
}
#[tokio::test]
async fn test_find_references_fallback_to_file_links() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test-fallback/docs");
let target_file = docs_dir.join("file-to-link-to.md");
let source_file = docs_dir.join("file-to-link-from.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "---\ntitle: Heading\n---\n\nWe are linking to this file from `file-to-link-from.md`.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let target_fi = FileIndex::default();
index.insert_file(target_file.clone(), target_fi);
let mut source_fi = FileIndex::default();
source_fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "file-to-link-to.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 22,
});
index.insert_file(source_file.clone(), source_fi);
}
let position = Position { line: 4, character: 10 };
let result = server.handle_references(&target_uri, position).await;
assert!(result.is_some(), "Should find references to the file via fallback");
let locations = result.unwrap();
assert_eq!(locations.len(), 1, "Should find 1 reference from source file");
assert_eq!(
locations[0].uri,
Url::from_file_path(&source_file).unwrap(),
"Reference should come from the source file"
);
}
#[tokio::test]
async fn test_find_references_fallback_no_references() {
use crate::workspace_index::FileIndex;
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-nav-test-fallback2/docs/lonely.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Some text without anything special.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let fi = FileIndex::default();
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&uri, position).await;
assert!(result.is_none(), "Should return None when no references exist");
}
#[tokio::test]
async fn test_find_references_fallback_multiple_sources() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test-fallback3/docs");
let target_file = docs_dir.join("target.md");
let source_a = docs_dir.join("a.md");
let source_b = docs_dir.join("b.md");
let source_c = docs_dir.join("c.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "This file is referenced by multiple others.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let target_fi = FileIndex::default();
index.insert_file(target_file.clone(), target_fi);
for (source, fragment, line) in [(&source_a, "", 2), (&source_b, "section", 5), (&source_c, "", 10)] {
let mut fi = FileIndex::default();
fi.cross_file_links.push(CrossFileLinkIndex {
target_path: "target.md".to_string(),
fragment: fragment.to_string(),
line,
column: 1,
});
index.insert_file(source.clone(), fi);
}
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&target_uri, position).await;
assert!(result.is_some(), "Should find references from multiple files");
let locations = result.unwrap();
assert_eq!(
locations.len(),
3,
"Should find all 3 references regardless of fragment"
);
let uris: std::collections::HashSet<_> = locations.iter().map(|l| l.uri.clone()).collect();
assert!(uris.contains(&Url::from_file_path(&source_a).unwrap()));
assert!(uris.contains(&Url::from_file_path(&source_b).unwrap()));
assert!(uris.contains(&Url::from_file_path(&source_c).unwrap()));
}
#[tokio::test]
async fn test_find_references_heading_takes_priority_over_fallback() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-nav-test-fallback4/docs");
let target_file = docs_dir.join("guide.md");
let source_with_anchor = docs_dir.join("a.md");
let source_without_anchor = docs_dir.join("b.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "# Installation\n\nHow to install.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut target_fi = FileIndex::default();
target_fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), target_fi);
let mut fi_a = FileIndex::default();
fi_a.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "installation".to_string(),
line: 3,
column: 5,
});
index.insert_file(source_with_anchor.clone(), fi_a);
let mut fi_b = FileIndex::default();
fi_b.cross_file_links.push(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "".to_string(),
line: 7,
column: 10,
});
index.insert_file(source_without_anchor.clone(), fi_b);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_references(&target_uri, position).await;
assert!(result.is_some(), "Should find heading-specific references");
let locations = result.unwrap();
assert_eq!(
locations.len(),
1,
"Should only find anchor-matching reference, not file-level"
);
assert_eq!(
locations[0].uri,
Url::from_file_path(&source_with_anchor).unwrap(),
"Should find the anchor-specific reference"
);
}
#[tokio::test]
async fn test_hover_inline_link_to_file() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test1/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "# Index\n\nSee [the guide](guide.md) for details.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "# Guide\n\nThis is the guide.\n\nIt has multiple lines.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 20 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover for file link");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("guide.md"), "Should contain filename");
assert!(
markup.value.contains("This is the guide"),
"Should contain file content"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_inline_link_with_anchor() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test2/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "# Index\n\nSee [install](guide.md#installation) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "# Guide\n\nIntro text.\n\n## Installation\n\nRun `cargo install rumdl`.\n\nThen configure.\n\n## Usage\n\nRun it.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Installation".to_string(),
auto_anchor: "installation".to_string(),
custom_anchor: None,
line: 5,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Usage".to_string(),
auto_anchor: "usage".to_string(),
custom_anchor: None,
line: 11,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 2, character: 18 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover for anchor link");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(
markup.value.contains("## Installation"),
"Should contain the heading: got '{}'",
markup.value
);
assert!(
markup.value.contains("cargo install rumdl"),
"Should contain section content"
);
assert!(
!markup.value.contains("## Usage"),
"Should stop at next heading of equal level"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_reference_style_link() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test3/docs");
let current_file = docs_dir.join("readme.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [the guide][guide] for details.\n\n[guide]: guide.md\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "# Guide\n\nWelcome to the guide.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 0, character: 8 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover for reference-style link");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("guide.md"), "Should contain filename");
assert!(
markup.value.contains("Welcome to the guide"),
"Should contain file content"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_external_url() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-hover-test4/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Visit [example](https://example.com) for more.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 18 };
let result = server.handle_hover(&uri, position).await;
assert!(result.is_some(), "Should return hover for external URL");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("https://example.com"), "Should show the URL");
assert!(markup.value.contains("External link"), "Should indicate it's external");
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_plain_text_returns_none() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-hover-test5/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Just some plain text here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 5 };
let result = server.handle_hover(&uri, position).await;
assert!(result.is_none(), "Should return None when cursor is not on a link");
}
#[tokio::test]
async fn test_hover_nonexistent_file_returns_none() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-hover-test6/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "See [missing](nonexistent.md) file.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 18 };
let result = server.handle_hover(&uri, position).await;
assert!(result.is_none(), "Should return None for link to nonexistent file");
}
#[tokio::test]
async fn test_hover_same_file_anchor() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-hover-test7/readme.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "# Title\n\nSee [below](#configuration) for config.\n\nSome text.\n\n## Configuration\n\nSet `key = value` in config.\n\nMore config details.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Title".to_string(),
auto_anchor: "title".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 7,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 2, character: 16 };
let result = server.handle_hover(&uri, position).await;
assert!(result.is_some(), "Should return hover for same-file anchor");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("## Configuration"), "Should contain the heading");
assert!(markup.value.contains("key = value"), "Should contain section content");
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_file_preview_truncates() {
use crate::workspace_index::FileIndex;
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test8/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("long.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [long file](long.md) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let long_content: String = (1..=30).map(|i| format!("Line {i}\n")).collect();
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: long_content.clone(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
index.insert_file(target_file.clone(), FileIndex::default());
}
let position = Position { line: 0, character: 18 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover for long file");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("Line 1"), "Should contain first line");
assert!(markup.value.contains("Line 15"), "Should contain line 15");
assert!(!markup.value.contains("Line 16"), "Should not contain line 16");
assert!(markup.value.contains("..."), "Should indicate truncation");
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_anchor_section_end_no_ellipsis() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test9/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [install](guide.md#install) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "# Guide\n\n## Install\n\nRun `cargo install`.\n\n## Usage\n\nRun the binary.\n\nMore usage info.\n\nEven more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n\nAnd more.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Install".to_string(),
auto_anchor: "install".to_string(),
custom_anchor: None,
line: 3,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Usage".to_string(),
auto_anchor: "usage".to_string(),
custom_anchor: None,
line: 7,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 0, character: 18 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover for anchor link");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("## Install"), "Should contain the heading");
assert!(markup.value.contains("cargo install"), "Should contain section content");
assert!(!markup.value.contains("## Usage"), "Should stop at next heading");
assert!(
!markup.value.contains("..."),
"Should NOT show ellipsis when section ended at heading boundary"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_anchor_preview_skips_code_block_hashes() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test10/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [config](guide.md#configuration) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "## Configuration\n\nExample:\n\n```python\n# This is a Python comment\nconfig = True\n```\n\nMore config info.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Configuration".to_string(),
auto_anchor: "configuration".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 0, character: 18 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(
markup.value.contains("# This is a Python comment"),
"Should include code block content (not treat # as heading): got '{}'",
markup.value
);
assert!(
markup.value.contains("More config info"),
"Should include content after code block"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_empty_file() {
use crate::workspace_index::FileIndex;
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test11/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("empty.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [empty](empty.md) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: String::new(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
index.insert_file(target_file.clone(), FileIndex::default());
}
let position = Position { line: 0, character: 15 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover even for empty file");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("empty.md"), "Should show filename");
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_anchor_does_not_stop_at_hashtag_word() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test12/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [tags](guide.md#tags) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "## Tags\n\nUse #markdown and #linting tags.\n\nThey help organize.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Tags".to_string(),
auto_anchor: "tags".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 0, character: 15 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(
markup.value.contains("#markdown"),
"Should NOT treat #hashtag as a heading and stop: got '{}'",
markup.value
);
assert!(
markup.value.contains("They help organize"),
"Should include content after hashtag line"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_hover_anchor_stops_at_indented_heading() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-hover-test13/docs");
let current_file = docs_dir.join("index.md");
let target_file = docs_dir.join("guide.md");
let current_uri = Url::from_file_path(¤t_file).unwrap();
let target_uri = Url::from_file_path(&target_file).unwrap();
let content = "See [intro](guide.md#intro) here.\n";
server.documents.write().await.insert(
current_uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let target_content = "## Intro\n\nSome intro text.\n\n ## Next Section\n\nNext content.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Intro".to_string(),
auto_anchor: "intro".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Next Section".to_string(),
auto_anchor: "next-section".to_string(),
custom_anchor: None,
line: 5,
is_setext: false,
});
index.insert_file(target_file.clone(), fi);
}
let position = Position { line: 0, character: 15 };
let result = server.handle_hover(¤t_uri, position).await;
assert!(result.is_some(), "Should return hover");
let hover = result.unwrap();
if let HoverContents::Markup(markup) = hover.contents {
assert!(markup.value.contains("Some intro text"), "Should contain intro content");
assert!(
!markup.value.contains("Next Section"),
"Should stop at indented heading of same level"
);
} else {
panic!("Expected Markup hover contents");
}
}
#[tokio::test]
async fn test_prepare_rename_atx_heading() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test1/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Installation Guide\n\nSome content.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_prepare_rename(&uri, position).await;
assert!(result.is_some(), "Should allow renaming ATX heading");
if let Some(PrepareRenameResponse::Range(range)) = result {
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 3); assert_eq!(range.end.character, 21); } else {
panic!("Expected Range response");
}
}
#[tokio::test]
async fn test_prepare_rename_setext_heading() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test2/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Installation Guide\n==================\n\nContent.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: true,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_prepare_rename(&uri, position).await;
assert!(result.is_some(), "Should allow renaming Setext heading");
if let Some(PrepareRenameResponse::Range(range)) = result {
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.character, 18); } else {
panic!("Expected Range response");
}
}
#[tokio::test]
async fn test_prepare_rename_not_heading() {
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test3/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "Just some text here.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
let position = Position { line: 0, character: 5 };
let result = server.handle_prepare_rename(&uri, position).await;
assert!(result.is_none(), "Should not allow renaming non-heading text");
}
#[tokio::test]
async fn test_rename_heading_updates_same_file_links() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test4/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content =
"## Getting Started\n\nSee [below](#getting-started) for details.\n\nMore [info](#getting-started).\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Getting Started".to_string(),
auto_anchor: "getting-started".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&uri, position, "Quick Start").await;
assert!(result.is_some(), "Should produce a workspace edit");
let edit = result.unwrap();
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).expect("Should have edits for the document");
assert_eq!(edits.len(), 3, "Should have 3 edits: heading + 2 link anchors");
let heading_edit = edits.iter().find(|e| e.range.start.line == 0).unwrap();
assert_eq!(heading_edit.new_text, "Quick Start");
let link_edits: Vec<_> = edits.iter().filter(|e| e.new_text == "quick-start").collect();
assert_eq!(link_edits.len(), 2, "Should update both link anchors");
}
#[tokio::test]
async fn test_rename_heading_with_custom_anchor_only_changes_text() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test5/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Guide {#install}\n\nSee [link](#install) for details.\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Guide".to_string(),
auto_anchor: "guide".to_string(),
custom_anchor: Some("install".to_string()),
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&uri, position, "Tutorial").await;
assert!(result.is_some(), "Should produce a workspace edit");
let edit = result.unwrap();
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).expect("Should have edits");
assert_eq!(edits.len(), 1, "Should only have 1 edit: heading text change");
assert_eq!(edits[0].new_text, "Tutorial");
}
#[tokio::test]
async fn test_rename_heading_updates_cross_file_links() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-rename-test6/docs");
let target_file = docs_dir.join("guide.md");
let source_file = docs_dir.join("index.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let source_uri = Url::from_file_path(&source_file).unwrap();
let target_content = "## API Reference\n\nAPI docs here.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
let source_content = "See [api](guide.md#api-reference) for details.\n";
server.documents.write().await.insert(
source_uri.clone(),
DocumentEntry {
content: source_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut target_fi = FileIndex::default();
target_fi.add_heading(HeadingIndex {
text: "API Reference".to_string(),
auto_anchor: "api-reference".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), target_fi);
let mut source_fi = FileIndex::default();
source_fi.add_cross_file_link(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "api-reference".to_string(),
line: 1,
column: 11, });
index.insert_file(source_file.clone(), source_fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&target_uri, position, "REST API").await;
assert!(result.is_some(), "Should produce workspace edit");
let edit = result.unwrap();
let changes = edit.changes.unwrap();
let target_edits = changes.get(&target_uri).expect("Should have target edits");
assert!(
target_edits.iter().any(|e| e.new_text == "REST API"),
"Should rename the heading text"
);
let source_edits = changes.get(&source_uri).expect("Should have source edits");
assert!(
source_edits.iter().any(|e| e.new_text == "rest-api"),
"Should update the cross-file link anchor"
);
}
#[tokio::test]
async fn test_rename_refuses_empty_name() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test7/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Heading\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Heading".to_string(),
auto_anchor: "heading".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&uri, position, "").await;
assert!(result.is_none(), "Should refuse empty rename");
let result = server.handle_rename(&uri, position, " ").await;
assert!(result.is_none(), "Should refuse whitespace-only rename");
}
#[tokio::test]
async fn test_rename_refuses_anchor_collision() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test8/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Foo\n\n## Bar\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Foo".to_string(),
auto_anchor: "foo".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
fi.add_heading(HeadingIndex {
text: "Bar".to_string(),
auto_anchor: "bar".to_string(),
custom_anchor: None,
line: 3,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&uri, position, "Bar").await;
assert!(result.is_none(), "Should refuse rename that causes anchor collision");
}
#[tokio::test]
async fn test_rename_heading_with_closing_atx() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test9/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Hello ##\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Hello".to_string(),
auto_anchor: "hello".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_prepare_rename(&uri, position).await;
assert!(result.is_some());
if let Some(PrepareRenameResponse::Range(range)) = result {
assert_eq!(range.start.character, 3);
assert_eq!(range.end.character, 8); } else {
panic!("Expected Range response");
}
}
#[tokio::test]
async fn test_rename_updates_same_file_ref_definitions() {
use crate::workspace_index::{FileIndex, HeadingIndex};
let server = create_test_server();
let file = std::path::PathBuf::from("/tmp/rumdl-rename-test10/doc.md");
let uri = Url::from_file_path(&file).unwrap();
let content = "## Getting Started\n\nSee [ref] for info.\n\n[ref]: #getting-started\n";
server.documents.write().await.insert(
uri.clone(),
DocumentEntry {
content: content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut fi = FileIndex::default();
fi.add_heading(HeadingIndex {
text: "Getting Started".to_string(),
auto_anchor: "getting-started".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(file.clone(), fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&uri, position, "Quick Start").await;
assert!(result.is_some());
let edit = result.unwrap();
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).expect("Should have edits");
let heading_edits: Vec<_> = edits.iter().filter(|e| e.new_text == "Quick Start").collect();
assert_eq!(heading_edits.len(), 1, "Should have 1 heading text edit");
let anchor_edits: Vec<_> = edits.iter().filter(|e| e.new_text == "quick-start").collect();
assert_eq!(
anchor_edits.len(),
1,
"Should update the reference definition anchor. Got {} edits",
anchor_edits.len()
);
}
#[tokio::test]
async fn test_rename_cross_file_multiple_links_same_line() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
let server = create_test_server();
let docs_dir = std::path::PathBuf::from("/tmp/rumdl-rename-test11/docs");
let target_file = docs_dir.join("guide.md");
let source_file = docs_dir.join("index.md");
let target_uri = Url::from_file_path(&target_file).unwrap();
let source_uri = Url::from_file_path(&source_file).unwrap();
let target_content = "## Getting Started\n\nContent here.\n";
server.documents.write().await.insert(
target_uri.clone(),
DocumentEntry {
content: target_content.to_string(),
version: Some(1),
from_disk: false,
},
);
let source_content = "See [a](guide.md#getting-started) and [b](guide.md#getting-started) here.\n";
server.documents.write().await.insert(
source_uri.clone(),
DocumentEntry {
content: source_content.to_string(),
version: Some(1),
from_disk: false,
},
);
{
let mut index = server.workspace_index.write().await;
let mut target_fi = FileIndex::default();
target_fi.add_heading(HeadingIndex {
text: "Getting Started".to_string(),
auto_anchor: "getting-started".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
index.insert_file(target_file.clone(), target_fi);
let mut source_fi = FileIndex::default();
source_fi.add_cross_file_link(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "getting-started".to_string(),
line: 1,
column: 9,
});
source_fi.add_cross_file_link(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "getting-started".to_string(),
line: 1,
column: 43,
});
index.insert_file(source_file.clone(), source_fi);
}
let position = Position { line: 0, character: 5 };
let result = server.handle_rename(&target_uri, position, "Quick Start").await;
assert!(result.is_some());
let edit = result.unwrap();
let changes = edit.changes.unwrap();
let source_edits = changes.get(&source_uri).expect("Should have source edits");
let anchor_edits: Vec<_> = source_edits.iter().filter(|e| e.new_text == "quick-start").collect();
assert_eq!(
anchor_edits.len(),
2,
"Should update both cross-file link anchors on the same line"
);
assert_ne!(
anchor_edits[0].range.start.character, anchor_edits[1].range.start.character,
"Edits should target different positions on the line"
);
}