#![allow(deprecated)]
use rumdl_lib::lsp::RumdlLanguageServer;
use rumdl_lib::lsp::types::{RumdlLspConfig, warning_to_code_action, warning_to_diagnostic};
use rumdl_lib::rule::{Fix, LintWarning, Severity};
use tower_lsp::lsp_types::*;
use tower_lsp::{LanguageServer, LspService};
use url::Url;
#[test]
fn test_rumdl_lsp_config_defaults() {
let config = RumdlLspConfig::default();
assert_eq!(config.config_path, None);
assert!(config.enable_linting);
assert!(!config.enable_auto_fix);
}
#[test]
fn test_rumdl_lsp_config_serde() {
let config = RumdlLspConfig {
config_path: Some("/path/to/config.toml".to_string()),
enable_linting: true,
enable_auto_fix: false,
enable_rules: None,
disable_rules: None,
..Default::default()
};
let json = serde_json::to_string(&config).expect("Failed to serialize config");
let deserialized: RumdlLspConfig = serde_json::from_str(&json).expect("Failed to deserialize config");
assert_eq!(deserialized.config_path, config.config_path);
assert_eq!(deserialized.enable_linting, config.enable_linting);
assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
}
#[test]
fn test_warning_to_diagnostic_conversion() {
let warning = LintWarning {
message: "Test warning message".to_string(),
line: 5,
column: 10,
end_line: 5,
end_column: 15,
severity: Severity::Warning,
fix: None,
rule_name: Some("MD001".to_string()),
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.message, "Test warning message");
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(diagnostic.source, Some("rumdl".to_string()));
assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4); assert_eq!(diagnostic.range.end.character, 14);
if let Some(NumberOrString::String(code)) = diagnostic.code {
assert_eq!(code, "MD001");
} else {
panic!("Expected string code");
}
}
#[test]
fn test_warning_to_diagnostic_error_severity() {
let warning = LintWarning {
message: "Test error message".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Error,
fix: None,
rule_name: Some("MD999".to_string()),
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn test_warning_to_code_action_with_fix() {
let warning = LintWarning {
message: "Line too long".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 47,
severity: Severity::Warning,
fix: Some(Fix::new(0..47, "shorter text".to_string())),
rule_name: Some("MD013".to_string()),
};
let uri = Url::parse("file:///test.md").expect("Invalid URI");
let document_text = "This line is too long and needs to be shortened";
let code_action = warning_to_code_action(&warning, &uri, document_text);
assert!(code_action.is_some());
let action = code_action.unwrap();
assert_eq!(action.title, "Fix: Line too long");
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
assert!(action.edit.is_some());
let edit = action.edit.unwrap();
assert!(edit.changes.is_some());
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).expect("Missing file edits");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "shorter text");
}
#[test]
fn test_warning_to_code_action_without_fix() {
let warning = LintWarning {
message: "No fix available".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: None,
rule_name: Some("MD001".to_string()),
};
let uri = Url::parse("file:///test.md").expect("Invalid URI");
let document_text = "Test document content";
let code_action = warning_to_code_action(&warning, &uri, document_text);
assert!(code_action.is_none());
}
#[tokio::test]
async fn test_lsp_server_initialization() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
let init_result = result.unwrap();
let caps = init_result.capabilities;
assert!(caps.text_document_sync.is_some());
assert!(caps.code_action_provider.is_some());
assert!(caps.diagnostic_provider.is_some());
assert!(
caps.document_formatting_provider.is_some(),
"Server must advertise document formatting capability"
);
assert!(init_result.server_info.is_some());
let server_info = init_result.server_info.unwrap();
assert_eq!(server_info.name, "rumdl");
assert!(server_info.version.is_some());
}
#[tokio::test]
async fn test_lsp_server_initialization_with_config() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let custom_config = RumdlLspConfig {
config_path: Some("/custom/path/.rumdl.toml".to_string()),
enable_linting: true,
enable_auto_fix: true,
enable_rules: None,
disable_rules: None,
..Default::default()
};
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: Some(serde_json::to_value(custom_config).unwrap()),
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
let result = service.inner().initialize(init_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_document_lifecycle() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///test.md").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "# Test Document\n\nThis is a test.".to_string(),
},
};
server.did_open(open_params).await;
let change_params = DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "# Test Document\n\nThis is a test with more content.".to_string(),
}],
};
server.did_change(change_params).await;
let save_params = DidSaveTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
text: None,
};
server.did_save(save_params).await;
let close_params = DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
};
server.did_close(close_params).await;
}
#[tokio::test]
async fn test_code_action_request() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///test.md").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "# Test\n\n# Heading with extra space".to_string(),
},
};
server.did_open(open_params).await;
let code_action_params = CodeActionParams {
text_document: TextDocumentIdentifier { uri },
range: Range {
start: Position { line: 2, character: 0 },
end: Position { line: 2, character: 25 },
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = server.code_action(code_action_params).await;
assert!(result.is_ok());
let _actions = result.unwrap();
}
#[tokio::test]
async fn test_diagnostic_request() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///test.md").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "# Test Document\n\nContent here".to_string(),
},
};
server.did_open(open_params).await;
let diagnostic_params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = server.diagnostic(diagnostic_params).await;
assert!(result.is_ok());
let report = result.unwrap();
match report {
DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(_)) => {
}
_ => panic!("Unexpected diagnostic report type"),
}
}
#[tokio::test]
async fn test_diagnostic_clears_on_close() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let config_path = std::env::temp_dir().join("rumdl_nonexistent.toml");
let _ = std::fs::remove_file(&config_path);
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: Some(serde_json::json!({
"configPath": config_path.to_string_lossy(),
})),
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///test.md").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "#Heading\n\nThis is a test.".to_string(),
},
};
server.did_open(open_params).await;
let diagnostic_params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let report = server.diagnostic(diagnostic_params).await.unwrap();
let items = match report {
DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) => {
report.full_document_diagnostic_report.items
}
_ => panic!("Unexpected diagnostic report type"),
};
assert!(
!items.is_empty(),
"Expected diagnostics for open document with a missing heading space"
);
server
.did_close(DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
})
.await;
let report = server
.diagnostic(DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.await
.unwrap();
let items = match report {
DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) => {
report.full_document_diagnostic_report.items
}
_ => panic!("Unexpected diagnostic report type"),
};
assert!(items.is_empty(), "Expected diagnostics to clear after close");
}
#[tokio::test]
async fn test_real_workflow_integration() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///workspace").unwrap()),
initialization_options: Some(serde_json::json!({
"enableLinting": true,
"enableAutoFix": false
})),
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
let init_result = server.initialize(init_params).await.unwrap();
assert!(init_result.capabilities.text_document_sync.is_some());
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///workspace/test.md").unwrap();
let problematic_content = r#"# Heading with extra space
This is a line that is way too long and should trigger MD013 line length warning because it exceeds the default limit.
Another paragraph.
"#;
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: problematic_content.to_string(),
},
};
server.did_open(open_params).await;
let diagnostic_params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let _diagnostic_result = server.diagnostic(diagnostic_params).await.unwrap();
let code_action_params = CodeActionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range: Range {
start: Position { line: 0, character: 0 },
end: Position { line: 0, character: 25 },
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let code_action_result = server.code_action(code_action_params).await;
assert!(code_action_result.is_ok());
let change_params = DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "# Fixed Heading\n\nShort line.\n\nAnother paragraph.".to_string(),
}],
};
server.did_change(change_params).await;
let save_params = DidSaveTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
text: None,
};
server.did_save(save_params).await;
let close_params = DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri },
};
server.did_close(close_params).await;
let shutdown_result = server.shutdown().await;
assert!(shutdown_result.is_ok());
}
#[cfg(test)]
mod edge_cases {
use super::*;
#[test]
fn test_warning_to_diagnostic_no_rule_name() {
let warning = LintWarning {
message: "Test message".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: None,
rule_name: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.code, None);
}
#[test]
fn test_warning_to_diagnostic_zero_position() {
let warning = LintWarning {
message: "Test message".to_string(),
line: 0,
column: 0,
end_line: 0,
end_column: 5,
severity: Severity::Warning,
fix: None,
rule_name: Some("MD001".to_string()),
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.range.start.line, 0);
assert_eq!(diagnostic.range.start.character, 0);
}
#[tokio::test]
async fn test_empty_document_handling() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let init_params = InitializeParams {
process_id: None,
root_path: None, root_uri: Some(Url::parse("file:///test").unwrap()),
initialization_options: None,
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///empty.md").unwrap();
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: "".to_string(),
},
};
server.did_open(open_params).await;
let diagnostic_params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = server.diagnostic(diagnostic_params).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_global_config_fallback_when_no_project_config() {
let (service, _socket) = LspService::new(|client| RumdlLanguageServer::new(client, None));
let server = service.inner();
let fake_workspace = Url::parse("file:///nonexistent/workspace").unwrap();
let init_params = InitializeParams {
process_id: None,
root_path: None,
root_uri: Some(fake_workspace),
initialization_options: Some(serde_json::json!({
"enableLinting": true,
"disableRules": ["MD041"] })),
capabilities: ClientCapabilities::default(),
trace: None,
workspace_folders: None,
client_info: None,
locale: None,
};
server.initialize(init_params).await.unwrap();
server.initialized(InitializedParams {}).await;
let uri = Url::parse("file:///nonexistent/test.md").unwrap();
let content = "This is not a heading\n\n# Heading later";
let open_params = DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "markdown".to_string(),
version: 1,
text: content.to_string(),
},
};
server.did_open(open_params).await;
let diagnostic_params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri },
identifier: None,
previous_result_id: None,
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
let result = server.diagnostic(diagnostic_params).await.unwrap();
match result {
DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) => {
let has_md041 = report
.full_document_diagnostic_report
.items
.iter()
.any(|d| matches!(&d.code, Some(NumberOrString::String(code)) if code == "MD041"));
assert!(
!has_md041,
"MD041 should be disabled via global config, but it was triggered. \
This indicates the server fell back to Config::default() instead of using global config."
);
}
_ => panic!("Expected full diagnostic report"),
}
}
}