use crate::rules::md013_line_length::MD013Config;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tower_lsp::lsp_types::*;
#[derive(Debug, Clone, PartialEq)]
pub enum IndexState {
Building {
progress: f32,
files_indexed: usize,
total_files: usize,
},
Ready,
Error(String),
}
impl Default for IndexState {
fn default() -> Self {
Self::Building {
progress: 0.0,
files_indexed: 0,
total_files: 0,
}
}
}
#[derive(Debug)]
pub enum IndexUpdate {
FileChanged { path: PathBuf, content: String },
FileDeleted { path: PathBuf },
FullRescan,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ConfigurationPreference {
#[default]
EditorFirst,
FilesystemFirst,
EditorOnly,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LspRuleSettings {
pub line_length: Option<usize>,
pub disable: Option<Vec<String>>,
pub enable: Option<Vec<String>>,
#[serde(flatten)]
pub rules: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RumdlLspConfig {
pub config_path: Option<String>,
pub enable_linting: bool,
pub enable_auto_fix: bool,
pub enable_rules: Option<Vec<String>>,
pub disable_rules: Option<Vec<String>>,
pub configuration_preference: ConfigurationPreference,
pub settings: Option<LspRuleSettings>,
pub enable_link_completions: bool,
}
impl Default for RumdlLspConfig {
fn default() -> Self {
Self {
config_path: None,
enable_linting: true,
enable_auto_fix: false,
enable_rules: None,
disable_rules: None,
configuration_preference: ConfigurationPreference::default(),
settings: None,
enable_link_completions: true,
}
}
}
pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
let start_position = Position {
line: (warning.line.saturating_sub(1)) as u32,
character: (warning.column.saturating_sub(1)) as u32,
};
let end_position = Position {
line: (warning.end_line.saturating_sub(1)) as u32,
character: (warning.end_column.saturating_sub(1)) as u32,
};
let severity = match warning.severity {
crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
};
let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
let is_rumdl_rule = rule_name.len() > 2
&& rule_name[..2].eq_ignore_ascii_case("MD")
&& rule_name[2..].chars().all(|c| c.is_ascii_digit());
if is_rumdl_rule {
Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
.ok()
.map(|href| CodeDescription { href })
} else {
None
}
});
Diagnostic {
range: Range {
start: start_position,
end: end_position,
},
severity: Some(severity),
code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
source: Some("rumdl".to_string()),
message: warning.message.clone(),
related_information: None,
tags: None,
code_description,
data: None,
}
}
fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
let mut line = 0u32;
let mut character = 0u32;
let mut byte_pos = 0;
let mut start_pos = None;
let mut end_pos = None;
for ch in text.chars() {
if byte_pos == byte_range.start {
start_pos = Some(Position { line, character });
}
if byte_pos == byte_range.end {
end_pos = Some(Position { line, character });
break;
}
if ch == '\n' {
line += 1;
character = 0;
} else {
character += 1;
}
byte_pos += ch.len_utf8();
}
if start_pos.is_none() && byte_pos >= byte_range.start {
start_pos = Some(Position { line, character });
}
if end_pos.is_none() && byte_pos >= byte_range.end {
end_pos = Some(Position { line, character });
}
match (start_pos, end_pos) {
(Some(start), Some(end)) => Some(Range { start, end }),
_ => {
log::warn!(
"Failed to convert byte range {:?} to LSP range for text of length {}",
byte_range,
text.len()
);
None
}
}
}
pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
}
pub(crate) fn warning_to_code_actions_with_md013_config(
warning: &crate::rule::LintWarning,
uri: &Url,
document_text: &str,
md013_config: Option<&MD013Config>,
) -> Vec<CodeAction> {
let mut actions = Vec::new();
if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
actions.push(fix_action);
}
if warning.rule_name.as_deref() == Some("MD013")
&& warning.fix.is_none()
&& let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
{
actions.push(reflow_action);
}
if warning.rule_name.as_deref() == Some("MD034")
&& let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
{
actions.push(convert_action);
}
if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
actions.push(ignore_line_action);
}
actions
}
fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
if let Some(fix) = &warning.fix {
let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
let edit = TextEdit {
range,
new_text: fix.replacement.clone(),
};
let mut changes = std::collections::HashMap::new();
changes.insert(uri.clone(), vec![edit]);
let workspace_edit = WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
Some(CodeAction {
title: format!("Fix: {}", warning.message),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![warning_to_diagnostic(warning)]),
edit: Some(workspace_edit),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
})
} else {
None
}
}
fn create_reflow_action(
warning: &crate::rule::LintWarning,
uri: &Url,
document_text: &str,
md013_config: Option<&MD013Config>,
) -> Option<CodeAction> {
let options = if let Some(config) = md013_config {
config.to_reflow_options()
} else {
let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
crate::utils::text_reflow::ReflowOptions {
line_length,
..Default::default()
}
};
let reflow_result =
crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
let edit = TextEdit {
range,
new_text: reflow_result.reflowed_text,
};
let mut changes = std::collections::HashMap::new();
changes.insert(uri.clone(), vec![edit]);
let workspace_edit = WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
Some(CodeAction {
title: "Reflow paragraph".to_string(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![warning_to_diagnostic(warning)]),
edit: Some(workspace_edit),
command: None,
is_preferred: Some(false), disabled: None,
data: None,
})
}
fn extract_line_length_from_message(message: &str) -> Option<usize> {
let exceeds_idx = message.find("exceeds")?;
let after_exceeds = &message[exceeds_idx + 7..];
let num_str = after_exceeds.split_whitespace().next()?;
num_str.parse::<usize>().ok()
}
fn create_convert_to_link_action(
warning: &crate::rule::LintWarning,
uri: &Url,
document_text: &str,
) -> Option<CodeAction> {
let fix = warning.fix.as_ref()?;
let url = extract_url_from_fix_replacement(&fix.replacement)?;
let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
let link_text = extract_domain_for_placeholder(url);
let new_text = format!("[{link_text}]({url})");
let edit = TextEdit { range, new_text };
let mut changes = std::collections::HashMap::new();
changes.insert(uri.clone(), vec![edit]);
let workspace_edit = WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
Some(CodeAction {
title: "Convert to markdown link".to_string(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![warning_to_diagnostic(warning)]),
edit: Some(workspace_edit),
command: None,
is_preferred: Some(false), disabled: None,
data: None,
})
}
fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
let trimmed = replacement.trim();
if trimmed.starts_with('<') && trimmed.ends_with('>') {
Some(&trimmed[1..trimmed.len() - 1])
} else {
None
}
}
fn extract_domain_for_placeholder(url: &str) -> &str {
if url.contains('@') && !url.contains("://") {
return url;
}
url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
}
fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
let rule_id = warning.rule_name.as_ref()?;
let warning_line = warning.line.saturating_sub(1);
let lines: Vec<&str> = document_text.lines().collect();
let line_content = lines.get(warning_line)?;
if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
return None;
}
let line_end = Position {
line: warning_line as u32,
character: line_content.len() as u32,
};
let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
let edit = TextEdit {
range: Range {
start: line_end,
end: line_end,
},
new_text: comment,
};
let mut changes = std::collections::HashMap::new();
changes.insert(uri.clone(), vec![edit]);
Some(CodeAction {
title: format!("Ignore {rule_id} for this line"),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![warning_to_diagnostic(warning)]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: Some(false), disabled: None,
data: None,
})
}
#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
pub fn warning_to_code_action(
warning: &crate::rule::LintWarning,
uri: &Url,
document_text: &str,
) -> Option<CodeAction> {
warning_to_code_actions(warning, uri, document_text)
.into_iter()
.find(|action| action.is_preferred == Some(true))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::{Fix, LintWarning, Severity};
#[test]
fn test_rumdl_lsp_config_default() {
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_serialization() {
let config = RumdlLspConfig {
config_path: Some("/path/to/config.toml".to_string()),
enable_linting: false,
enable_auto_fix: true,
enable_rules: None,
disable_rules: None,
configuration_preference: ConfigurationPreference::EditorFirst,
settings: None,
enable_link_completions: true,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
assert!(json.contains("\"enableLinting\":false"));
assert!(json.contains("\"enableAutoFix\":true"));
let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
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_basic() {
let warning = LintWarning {
line: 5,
column: 10,
end_line: 5,
end_column: 15,
rule_name: Some("MD001".to_string()),
message: "Test warning message".to_string(),
severity: Severity::Warning,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
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);
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(diagnostic.source, Some("rumdl".to_string()));
assert_eq!(diagnostic.message, "Test warning message");
assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
}
#[test]
fn test_warning_to_diagnostic_error_severity() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD002".to_string()),
message: "Error message".to_string(),
severity: Severity::Error,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
}
#[test]
fn test_warning_to_diagnostic_no_rule_name() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: None,
message: "Generic warning".to_string(),
severity: Severity::Warning,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.code, None);
assert!(diagnostic.code_description.is_none());
}
#[test]
fn test_warning_to_diagnostic_edge_cases() {
let warning = LintWarning {
line: 0,
column: 0,
end_line: 0,
end_column: 0,
rule_name: Some("MD001".to_string()),
message: "Edge case".to_string(),
severity: Severity::Warning,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert_eq!(diagnostic.range.start.line, 0);
assert_eq!(diagnostic.range.start.character, 0);
}
#[test]
fn test_byte_range_to_lsp_range_simple() {
let text = "Hello\nWorld";
let range = byte_range_to_lsp_range(text, 0..5).unwrap();
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.line, 0);
assert_eq!(range.end.character, 5);
}
#[test]
fn test_byte_range_to_lsp_range_multiline() {
let text = "Hello\nWorld\nTest";
let range = byte_range_to_lsp_range(text, 6..11).unwrap();
assert_eq!(range.start.line, 1);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.line, 1);
assert_eq!(range.end.character, 5);
}
#[test]
fn test_byte_range_to_lsp_range_unicode() {
let text = "Hello 世界\nTest";
let range = byte_range_to_lsp_range(text, 6..12).unwrap();
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 6);
assert_eq!(range.end.line, 0);
assert_eq!(range.end.character, 8); }
#[test]
fn test_byte_range_to_lsp_range_eof() {
let text = "Hello";
let range = byte_range_to_lsp_range(text, 0..5).unwrap();
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.line, 0);
assert_eq!(range.end.character, 5);
}
#[test]
fn test_byte_range_to_lsp_range_invalid() {
let text = "Hello";
let range = byte_range_to_lsp_range(text, 10..15);
assert!(range.is_none());
}
#[test]
fn test_byte_range_to_lsp_range_insertion_at_eof() {
let text = "Hello\nWorld";
let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
assert_eq!(range.start.line, 1);
assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
assert_eq!(range.end.character, 5);
}
#[test]
fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
let text = "Hello\nWorld\n";
let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
assert_eq!(range.start.line, 2);
assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
assert_eq!(range.end.character, 0);
}
#[test]
fn test_warning_to_code_action_with_fix() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Missing space".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..5,
replacement: "Fixed".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello World";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert!(!actions.is_empty());
let action = &actions[0];
assert_eq!(action.title, "Fix: Missing space");
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
let edits = &changes[&uri];
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "Fixed");
}
#[test]
fn test_warning_to_code_action_no_fix() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "No fix available".to_string(),
severity: Severity::Warning,
fix: None,
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello World";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
}
#[test]
fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
let warning = LintWarning {
line: 2,
column: 1,
end_line: 2,
end_column: 100,
rule_name: Some("MD013".to_string()),
message: "Line length 95 exceeds 40 characters".to_string(),
severity: Severity::Warning,
fix: None,
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "> This quoted paragraph starts explicitly and is intentionally long enough for reflow.\nlazy continuation line should also be included when reflow is triggered from this warning.\n";
let actions = warning_to_code_actions(&warning, &uri, document_text);
let reflow_action = actions
.iter()
.find(|action| action.title == "Reflow paragraph")
.expect("Expected manual reflow action for MD013");
let changes = reflow_action
.edit
.as_ref()
.and_then(|edit| edit.changes.as_ref())
.expect("Expected edits for reflow action");
let file_edits = changes.get(&uri).expect("Expected edits for URI");
assert_eq!(file_edits.len(), 1);
assert!(
file_edits[0]
.new_text
.lines()
.next()
.is_some_and(|line| line.starts_with("> ")),
"Expected blockquote prefix in reflow output"
);
}
#[test]
fn test_warning_to_code_action_multiline_fix() {
let warning = LintWarning {
line: 2,
column: 1,
end_line: 3,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Multiline fix".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 6..16, replacement: "Fixed\nContent".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello\nWorld\nTest Line";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert!(!actions.is_empty());
let action = &actions[0];
let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
let edits = &changes[&uri];
assert_eq!(edits[0].new_text, "Fixed\nContent");
assert_eq!(edits[0].range.start.line, 1);
assert_eq!(edits[0].range.start.character, 0);
}
#[test]
fn test_code_description_url_generation() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD013".to_string()),
message: "Line too long".to_string(),
severity: Severity::Warning,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert!(diagnostic.code_description.is_some());
let url = diagnostic.code_description.unwrap().href;
assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
}
#[test]
fn test_no_url_for_code_block_tool_warnings() {
for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 10,
rule_name: Some(tool_name.to_string()),
message: "some tool warning".to_string(),
severity: Severity::Warning,
fix: None,
};
let diagnostic = warning_to_diagnostic(&warning);
assert!(
diagnostic.code_description.is_none(),
"Expected no URL for tool name '{tool_name}', but got one",
);
}
}
#[test]
fn test_lsp_config_partial_deserialization() {
let json = r#"{"enableLinting": false}"#;
let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
assert!(!config.enable_linting);
assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
#[test]
fn test_configuration_preference_serialization() {
let pref = ConfigurationPreference::EditorFirst;
let json = serde_json::to_string(&pref).unwrap();
assert_eq!(json, "\"editorFirst\"");
let pref = ConfigurationPreference::FilesystemFirst;
let json = serde_json::to_string(&pref).unwrap();
assert_eq!(json, "\"filesystemFirst\"");
let pref = ConfigurationPreference::EditorOnly;
let json = serde_json::to_string(&pref).unwrap();
assert_eq!(json, "\"editorOnly\"");
let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
}
#[test]
fn test_lsp_rule_settings_deserialization() {
let json = r#"{
"lineLength": 120,
"disable": ["MD001", "MD002"],
"enable": ["MD013"]
}"#;
let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.line_length, Some(120));
assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
}
#[test]
fn test_lsp_rule_settings_with_per_rule_config() {
let json = r#"{
"lineLength": 80,
"MD013": {
"lineLength": 120,
"codeBlocks": false
},
"MD024": {
"siblingsOnly": true
}
}"#;
let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.line_length, Some(80));
let md013 = settings.rules.get("MD013").unwrap();
assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
let md024 = settings.rules.get("MD024").unwrap();
assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
}
#[test]
fn test_full_lsp_config_with_settings() {
let json = r#"{
"configPath": "/path/to/config",
"enableLinting": true,
"enableAutoFix": false,
"configurationPreference": "editorFirst",
"settings": {
"lineLength": 100,
"disable": ["MD033"],
"MD013": {
"lineLength": 120,
"tables": false
}
}
}"#;
let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.config_path, Some("/path/to/config".to_string()));
assert!(config.enable_linting);
assert!(!config.enable_auto_fix);
assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
let settings = config.settings.unwrap();
assert_eq!(settings.line_length, Some(100));
assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
let md013 = settings.rules.get("MD013").unwrap();
assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
}
#[test]
fn test_create_ignore_line_action_uses_rumdl_syntax() {
let warning = LintWarning {
line: 5,
column: 1,
end_line: 5,
end_column: 50,
rule_name: Some("MD013".to_string()),
message: "Line too long".to_string(),
severity: Severity::Warning,
fix: None,
};
let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
let uri = Url::parse("file:///test.md").unwrap();
let action = create_ignore_line_action(&warning, &uri, document).unwrap();
assert_eq!(action.title, "Ignore MD013 for this line");
assert_eq!(action.is_preferred, Some(false));
assert!(action.edit.is_some());
let edit = action.edit.unwrap();
let changes = edit.changes.unwrap();
let file_edits = changes.get(&uri).unwrap();
assert_eq!(file_edits.len(), 1);
assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
assert!(!file_edits[0].new_text.contains("markdownlint"));
assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
#[test]
fn test_create_ignore_line_action_no_duplicate() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 50,
rule_name: Some("MD013".to_string()),
message: "Line too long".to_string(),
severity: Severity::Warning,
fix: None,
};
let document = "This is a line <!-- rumdl-disable-line MD013 -->";
let uri = Url::parse("file:///test.md").unwrap();
let action = create_ignore_line_action(&warning, &uri, document);
assert!(action.is_none());
}
#[test]
fn test_create_ignore_line_action_detects_markdownlint_syntax() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 50,
rule_name: Some("MD013".to_string()),
message: "Line too long".to_string(),
severity: Severity::Warning,
fix: None,
};
let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
let uri = Url::parse("file:///test.md").unwrap();
let action = create_ignore_line_action(&warning, &uri, document);
assert!(action.is_none());
}
#[test]
fn test_warning_to_code_actions_with_fix() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD009".to_string()),
message: "Trailing spaces".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..5,
replacement: "Fixed".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello \nWorld";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].title, "Fix: Trailing spaces");
assert_eq!(actions[0].is_preferred, Some(true));
assert_eq!(actions[1].title, "Ignore MD009 for this line");
assert_eq!(actions[1].is_preferred, Some(false));
}
#[test]
fn test_warning_to_code_actions_no_fix() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 10,
rule_name: Some("MD033".to_string()),
message: "Inline HTML".to_string(),
severity: Severity::Warning,
fix: None,
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "<div>HTML</div>";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].title, "Ignore MD033 for this line");
assert_eq!(actions[0].is_preferred, Some(false));
}
#[test]
fn test_warning_to_code_actions_no_rule_name() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: None,
message: "Generic warning".to_string(),
severity: Severity::Warning,
fix: None,
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello World";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert_eq!(actions.len(), 0);
}
#[test]
fn test_legacy_warning_to_code_action_compatibility() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: Some("MD001".to_string()),
message: "Test".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..5,
replacement: "Fixed".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "Hello World";
#[allow(deprecated)]
let action = warning_to_code_action(&warning, &uri, document_text);
assert!(action.is_some());
let action = action.unwrap();
assert_eq!(action.title, "Fix: Test");
assert_eq!(action.is_preferred, Some(true));
}
#[test]
fn test_md034_convert_to_link_action() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 25,
rule_name: Some("MD034".to_string()),
message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..20, replacement: "<https://example.com>".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "https://example.com is a test URL";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert_eq!(actions.len(), 3);
assert_eq!(
actions[0].title,
"Fix: URL without angle brackets or link formatting: 'https://example.com'"
);
assert_eq!(actions[0].is_preferred, Some(true));
assert_eq!(actions[1].title, "Convert to markdown link");
assert_eq!(actions[1].is_preferred, Some(false));
let edit = actions[1].edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let file_edits = changes.get(&uri).unwrap();
assert_eq!(file_edits.len(), 1);
assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
assert_eq!(actions[2].title, "Ignore MD034 for this line");
}
#[test]
fn test_md034_convert_to_link_action_email() {
let warning = LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 20,
rule_name: Some("MD034".to_string()),
message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..16, replacement: "<user@example.com>".to_string(),
}),
};
let uri = Url::parse("file:///test.md").unwrap();
let document_text = "user@example.com is my email";
let actions = warning_to_code_actions(&warning, &uri, document_text);
assert_eq!(actions.len(), 3);
assert_eq!(actions[1].title, "Convert to markdown link");
let edit = actions[1].edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let file_edits = changes.get(&uri).unwrap();
assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
}
#[test]
fn test_extract_url_from_fix_replacement() {
assert_eq!(
extract_url_from_fix_replacement("<https://example.com>"),
Some("https://example.com")
);
assert_eq!(
extract_url_from_fix_replacement("<user@example.com>"),
Some("user@example.com")
);
assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
}
#[test]
fn test_extract_domain_for_placeholder() {
assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
assert_eq!(
extract_domain_for_placeholder("https://example.com/path/to/page"),
"example.com"
);
assert_eq!(
extract_domain_for_placeholder("http://sub.example.com:8080/"),
"sub.example.com:8080"
);
assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
assert_eq!(
extract_domain_for_placeholder("ftp://files.example.com"),
"files.example.com"
);
}
#[test]
fn test_byte_range_to_lsp_range_trailing_newlines() {
let text = "line1\nline2\n\n";
let range = byte_range_to_lsp_range(text, 12..13);
assert!(range.is_some());
let range = range.unwrap();
assert_eq!(range.start.line, 2);
assert_eq!(range.start.character, 0);
assert_eq!(range.end.line, 3);
assert_eq!(range.end.character, 0);
}
#[test]
fn test_byte_range_to_lsp_range_at_eof() {
let text = "test\n";
let range = byte_range_to_lsp_range(text, 5..5);
assert!(range.is_some());
let range = range.unwrap();
assert_eq!(range.start.line, 1);
assert_eq!(range.start.character, 0);
}
}