use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use crate::config::{Config, MarkdownFlavor, RuleConfig};
use crate::fix_coordinator::FixCoordinator;
use crate::rule::{LintWarning, Severity};
use crate::rule_config_serde::{is_rule_name, json_to_rule_config_with_warnings, toml_value_to_json};
use crate::rules::{all_rules, filter_rules};
use crate::types::LineLength;
use crate::utils::utf8_offsets::{byte_column_to_char_column, byte_offset_to_char_offset, get_line_content};
#[derive(Serialize)]
struct JsWarning {
message: String,
line: usize,
column: usize,
end_line: usize,
end_column: usize,
severity: Severity,
#[serde(skip_serializing_if = "Option::is_none")]
fix: Option<JsFix>,
#[serde(skip_serializing_if = "Option::is_none")]
rule_name: Option<String>,
}
#[derive(Serialize)]
struct JsFix {
range: JsRange,
replacement: String,
}
#[derive(Serialize)]
struct JsRange {
start: usize,
end: usize,
}
fn convert_warning_for_js(warning: &LintWarning, content: &str) -> JsWarning {
let js_fix = warning.fix.as_ref().map(|fix| JsFix {
range: JsRange {
start: byte_offset_to_char_offset(content, fix.range.start),
end: byte_offset_to_char_offset(content, fix.range.end),
},
replacement: fix.replacement.clone(),
});
let column = get_line_content(content, warning.line)
.map(|line| byte_column_to_char_column(line, warning.column))
.unwrap_or(warning.column);
let end_column = get_line_content(content, warning.end_line)
.map(|line| byte_column_to_char_column(line, warning.end_column))
.unwrap_or(warning.end_column);
JsWarning {
message: warning.message.clone(),
line: warning.line,
column,
end_line: warning.end_line,
end_column,
severity: warning.severity,
fix: js_fix,
rule_name: warning.rule_name.clone(),
}
}
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
#[derive(Deserialize, Default, Debug)]
#[serde(rename_all = "kebab-case", default)]
pub struct LinterConfig {
pub disable: Option<Vec<String>>,
pub enable: Option<Vec<String>>,
pub extend_enable: Option<Vec<String>>,
pub extend_disable: Option<Vec<String>>,
pub line_length: Option<u64>,
pub flavor: Option<String>,
pub fixable: Option<Vec<String>>,
pub unfixable: Option<Vec<String>>,
#[serde(flatten)]
pub rules: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl LinterConfig {
fn to_config(&self) -> Config {
self.to_config_with_warnings().0
}
fn to_config_with_warnings(&self) -> (Config, Vec<String>) {
let mut config = Config::default();
let mut warnings = Vec::new();
if let Some(ref disable) = self.disable {
config.global.disable = disable.clone();
}
if let Some(ref enable) = self.enable {
config.global.enable = enable.clone();
config.global.enable_is_explicit = true;
}
if let Some(ref extend_enable) = self.extend_enable {
config.global.extend_enable = extend_enable.clone();
}
if let Some(ref extend_disable) = self.extend_disable {
config.global.extend_disable = extend_disable.clone();
}
if let Some(line_length) = self.line_length {
config.global.line_length = LineLength::new(line_length as usize);
}
config.global.flavor = self.markdown_flavor();
if let Some(ref fixable) = self.fixable {
config.global.fixable = fixable.clone();
}
if let Some(ref unfixable) = self.unfixable {
config.global.unfixable = unfixable.clone();
}
if let Some(ref rules) = self.rules {
for (rule_name, json_value) in rules {
if !is_rule_name(rule_name) {
continue;
}
let result = json_to_rule_config_with_warnings(json_value);
for warning in result.warnings {
warnings.push(format!("[{}] {}", rule_name.to_ascii_uppercase(), warning));
}
if let Some(rule_config) = result.config {
config.rules.insert(rule_name.to_ascii_uppercase(), rule_config);
}
}
}
config.apply_per_rule_enabled();
(config, warnings)
}
fn markdown_flavor(&self) -> MarkdownFlavor {
self.flavor
.as_deref()
.and_then(|s| s.parse::<MarkdownFlavor>().ok())
.unwrap_or_default()
}
}
#[wasm_bindgen]
pub struct Linter {
config: Config,
flavor: MarkdownFlavor,
config_warnings: Vec<String>,
}
#[wasm_bindgen]
impl Linter {
#[wasm_bindgen(constructor)]
pub fn new(options: JsValue) -> Result<Linter, JsValue> {
let linter_config: LinterConfig = if options.is_undefined() || options.is_null() {
LinterConfig::default()
} else {
serde_wasm_bindgen::from_value(options).map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?
};
let (config, config_warnings) = linter_config.to_config_with_warnings();
Ok(Linter {
config,
flavor: linter_config.markdown_flavor(),
config_warnings,
})
}
pub fn get_config_warnings(&self) -> String {
serde_json::to_string(&self.config_warnings).unwrap_or_else(|_| "[]".to_string())
}
pub fn check(&self, content: &str) -> String {
let all = all_rules(&self.config);
let rules = filter_rules(&all, &self.config.global);
match crate::lint(content, &rules, false, self.flavor, None, Some(&self.config)) {
Ok(warnings) => {
let js_warnings: Vec<JsWarning> = warnings.iter().map(|w| convert_warning_for_js(w, content)).collect();
serde_json::to_string(&js_warnings).unwrap_or_else(|_| "[]".to_string())
}
Err(e) => format!(r#"[{{"error": "{}"}}]"#, e),
}
}
pub fn fix(&self, content: &str) -> String {
let all = all_rules(&self.config);
let rules = filter_rules(&all, &self.config.global);
let warnings = match crate::lint(content, &rules, false, self.flavor, None, Some(&self.config)) {
Ok(w) => w,
Err(_) => return content.to_string(),
};
let coordinator = FixCoordinator::new();
let mut fixed_content = content.to_string();
match coordinator.apply_fixes_iterative(&rules, &warnings, &mut fixed_content, &self.config, 10, None) {
Ok(_) => fixed_content,
Err(_) => content.to_string(),
}
}
pub fn get_config(&self) -> String {
let rules_json: serde_json::Map<String, serde_json::Value> = self
.config
.rules
.iter()
.map(|(name, rule_config)| {
let values: serde_json::Map<String, serde_json::Value> = rule_config
.values
.iter()
.filter_map(|(k, v)| toml_value_to_json(v).map(|json_val| (k.clone(), json_val)))
.collect();
(name.clone(), serde_json::Value::Object(values))
})
.collect();
serde_json::json!({
"disable": self.config.global.disable,
"enable": self.config.global.enable,
"extend_enable": self.config.global.extend_enable,
"extend_disable": self.config.global.extend_disable,
"fixable": self.config.global.fixable,
"unfixable": self.config.global.unfixable,
"line_length": self.config.global.line_length.get(),
"flavor": self.flavor.to_string(),
"rules": rules_json
})
.to_string()
}
}
#[wasm_bindgen]
pub fn get_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[wasm_bindgen]
pub fn get_available_rules() -> String {
let config = Config::default();
let rules = all_rules(&config);
let rule_info: Vec<serde_json::Value> = rules
.iter()
.map(|r| {
serde_json::json!({
"name": r.name(),
"description": r.description()
})
})
.collect();
serde_json::to_string(&rule_info).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_version() {
let version = get_version();
assert!(!version.is_empty());
}
#[test]
fn test_get_available_rules() {
let rules_json = get_available_rules();
let rules: Vec<serde_json::Value> = serde_json::from_str(&rules_json).unwrap();
assert!(!rules.is_empty());
let has_md001 = rules.iter().any(|r| r["name"] == "MD001");
assert!(has_md001);
}
#[test]
fn test_linter_default_config() {
let config = LinterConfig::default();
assert!(config.disable.is_none());
assert!(config.enable.is_none());
assert!(config.line_length.is_none());
assert!(config.flavor.is_none());
}
#[test]
fn test_linter_config_to_config() {
let config = LinterConfig {
disable: Some(vec!["MD041".to_string()]),
enable: None,
line_length: Some(100),
flavor: Some("mkdocs".to_string()),
..Default::default()
};
let internal = config.to_config();
assert!(internal.global.disable.contains(&"MD041".to_string()));
assert_eq!(internal.global.line_length.get(), 100);
}
#[test]
fn test_linter_config_flavor() {
assert_eq!(
LinterConfig {
flavor: Some("standard".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Standard
);
assert_eq!(
LinterConfig {
flavor: Some("mkdocs".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::MkDocs
);
assert_eq!(
LinterConfig {
flavor: Some("mdx".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::MDX
);
assert_eq!(
LinterConfig {
flavor: Some("quarto".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Quarto
);
assert_eq!(
LinterConfig {
flavor: Some("obsidian".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Obsidian
);
assert_eq!(
LinterConfig {
flavor: Some("kramdown".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Kramdown
);
assert_eq!(
LinterConfig {
flavor: Some("jekyll".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Kramdown
);
assert_eq!(
LinterConfig {
flavor: None,
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Standard
);
}
#[test]
fn test_all_flavors_handled_in_wasm() {
let flavors = [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::MDX,
MarkdownFlavor::Quarto,
MarkdownFlavor::Obsidian,
MarkdownFlavor::Kramdown,
];
for flavor in flavors {
let flavor_str = match flavor {
MarkdownFlavor::Standard => "standard",
MarkdownFlavor::MkDocs => "mkdocs",
MarkdownFlavor::MDX => "mdx",
MarkdownFlavor::Quarto => "quarto",
MarkdownFlavor::Obsidian => "obsidian",
MarkdownFlavor::Kramdown => "kramdown",
};
let config = LinterConfig {
flavor: Some(flavor_str.to_string()),
..Default::default()
};
assert_eq!(
config.markdown_flavor(),
flavor,
"Round-trip failed for flavor: {:?}",
flavor
);
}
}
#[test]
fn test_linter_check_empty() {
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check("");
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_linter_check_with_issue() {
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "## Level 2\n\n#### Level 4";
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty());
}
#[test]
fn test_linter_check_with_disabled_rule() {
let config = LinterConfig {
disable: Some(vec!["MD001".to_string()]),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "## Level 2\n\n#### Level 4";
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
let has_md001 = warnings.iter().any(|w| w["rule_name"] == "MD001");
assert!(!has_md001, "MD001 should be disabled");
}
#[test]
fn test_linter_fix() {
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "Hello \nWorld";
let result = linter.fix(content);
assert!(!result.contains(" \n"));
}
#[test]
fn test_linter_fix_adjacent_blocks() {
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "# Heading\n```code\nblock\n```\n| Header |\n|--------|\n| Cell |";
let result = linter.fix(content);
assert!(!result.contains("\n\n\n"), "Should not have double blank lines");
}
#[test]
fn test_linter_get_config() {
let config = LinterConfig {
disable: Some(vec!["MD041".to_string()]),
flavor: Some("mkdocs".to_string()),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.get_config();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["flavor"], "mkdocs");
assert!(
parsed["disable"]
.as_array()
.unwrap()
.contains(&serde_json::Value::String("MD041".to_string()))
);
}
#[test]
fn test_check_norwegian_letter_fix_offset() {
let content = "# Heading\n\nContent with Norwegian letter \"æ\".";
assert_eq!(content.len(), 46); assert_eq!(content.chars().count(), 45);
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
let md047 = warnings.iter().find(|w| w["rule_name"] == "MD047");
assert!(md047.is_some(), "Should have MD047 warning");
let fix = md047.unwrap()["fix"].as_object().unwrap();
let range = fix["range"].as_object().unwrap();
assert_eq!(
range["start"].as_u64().unwrap(),
45,
"Fix start should be character offset 45, not byte offset 46"
);
assert_eq!(
range["end"].as_u64().unwrap(),
45,
"Fix end should be character offset 45"
);
}
#[test]
fn test_fix_norwegian_letter() {
let content = "# Heading\n\nContent with Norwegian letter \"æ\".";
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let fixed = linter.fix(content);
assert!(fixed.ends_with('\n'), "Should end with newline");
assert_eq!(fixed, "# Heading\n\nContent with Norwegian letter \"æ\".\n");
}
#[test]
fn test_check_norwegian_letter_column_offset() {
let content = "# Heading\n\nContent with Norwegian letter \"æ\".";
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
let md047 = warnings.iter().find(|w| w["rule_name"] == "MD047");
assert!(md047.is_some(), "Should have MD047 warning");
let warning = md047.unwrap();
assert_eq!(
warning["column"].as_u64().unwrap(),
35,
"Column should be char offset 35, not byte offset 36"
);
assert_eq!(
warning["end_column"].as_u64().unwrap(),
35,
"End column should also be char offset 35"
);
assert_eq!(warning["line"].as_u64().unwrap(), 3);
assert_eq!(warning["end_line"].as_u64().unwrap(), 3);
}
#[test]
fn test_check_multiple_multibyte_chars_column() {
let content = "# æøå\n\nLine with æ and ø here.";
let config = LinterConfig {
disable: Some(vec!["MD047".to_string()]), ..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
for warning in &warnings {
let line = warning["line"].as_u64().unwrap();
let column = warning["column"].as_u64().unwrap();
if line == 1 {
assert!(column <= 6, "Column {column} on line 1 exceeds char count (max 6)");
}
}
}
#[test]
fn test_check_emoji_column() {
let content = "# Test 👋\n\nHello";
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
for warning in &warnings {
let line = warning["line"].as_u64().unwrap();
let column = warning["column"].as_u64().unwrap();
if line == 1 {
assert!(
column <= 9, "Column {column} on line 1 with emoji should be char-based (max 9), not byte-based"
);
}
}
}
#[test]
fn test_check_japanese_column() {
let content = "# 日本語\n\nTest";
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
for warning in &warnings {
let line = warning["line"].as_u64().unwrap();
let column = warning["column"].as_u64().unwrap();
if line == 1 {
assert!(
column <= 6, "Column {column} on line 1 with Japanese should be char-based (max 6), not byte-based (would be 12)"
);
}
}
}
#[test]
fn test_linter_config_with_rule_configs() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"enabled": true,
"style": "aligned"
}),
);
rules.insert(
"MD013".to_string(),
serde_json::json!({
"line-length": 120,
"code-blocks": false
}),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
let md060 = internal.rules.get("MD060");
assert!(md060.is_some(), "MD060 should be in rules");
let md060_config = md060.unwrap();
assert_eq!(md060_config.values.get("enabled"), Some(&toml::Value::Boolean(true)));
assert_eq!(
md060_config.values.get("style"),
Some(&toml::Value::String("aligned".to_string()))
);
let md013 = internal.rules.get("MD013");
assert!(md013.is_some(), "MD013 should be in rules");
let md013_config = md013.unwrap();
assert_eq!(md013_config.values.get("line-length"), Some(&toml::Value::Integer(120)));
assert_eq!(
md013_config.values.get("code-blocks"),
Some(&toml::Value::Boolean(false))
);
}
#[test]
fn test_linter_config_rule_name_case_normalization() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"md060".to_string(), serde_json::json!({ "enabled": true }),
);
rules.insert(
"Md013".to_string(), serde_json::json!({ "enabled": true }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
assert!(internal.rules.contains_key("MD060"), "MD060 should be uppercase");
assert!(internal.rules.contains_key("MD013"), "MD013 should be uppercase");
}
#[test]
fn test_linter_config_ignores_non_rule_keys() {
let mut rules = std::collections::HashMap::new();
rules.insert("MD060".to_string(), serde_json::json!({ "enabled": true }));
rules.insert("not-a-rule".to_string(), serde_json::json!({ "value": 123 }));
rules.insert("global".to_string(), serde_json::json!({ "key": "value" }));
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
assert!(internal.rules.contains_key("MD060"));
assert!(!internal.rules.contains_key("not-a-rule"));
assert!(!internal.rules.contains_key("global"));
}
#[test]
fn test_get_config_includes_rules() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"enabled": true,
"style": "aligned"
}),
);
let config = LinterConfig {
disable: Some(vec!["MD041".to_string()]),
rules: Some(rules),
flavor: Some("mkdocs".to_string()),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.get_config();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["flavor"], "mkdocs");
assert!(parsed["rules"].is_object(), "rules should be an object");
let rules_obj = parsed["rules"].as_object().unwrap();
assert!(rules_obj.contains_key("MD060"), "MD060 should be in rules");
let md060 = &rules_obj["MD060"];
assert_eq!(md060["enabled"], true);
assert_eq!(md060["style"], "aligned");
}
#[test]
fn test_linter_config_deserializes_from_json() {
let json = serde_json::json!({
"disable": ["MD041"],
"line-length": 100,
"flavor": "mkdocs",
"MD060": {
"enabled": true,
"style": "aligned"
},
"MD013": {
"tables": false
}
});
let config: LinterConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.disable, Some(vec!["MD041".to_string()]));
assert_eq!(config.line_length, Some(100));
assert_eq!(config.flavor, Some("mkdocs".to_string()));
let rules = config.rules.as_ref().unwrap();
assert!(rules.contains_key("MD060"));
assert!(rules.contains_key("MD013"));
let md060 = &rules["MD060"];
assert_eq!(md060["enabled"], true);
assert_eq!(md060["style"], "aligned");
}
#[test]
fn test_linter_with_md044_names_config() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD044".to_string(),
serde_json::json!({
"names": ["JavaScript", "TypeScript", "GitHub"],
"code-blocks": false
}),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
let md044 = internal.rules.get("MD044").unwrap();
let names = md044.values.get("names").unwrap();
if let toml::Value::Array(arr) = names {
assert_eq!(arr.len(), 3);
assert_eq!(arr[0], toml::Value::String("JavaScript".to_string()));
assert_eq!(arr[1], toml::Value::String("TypeScript".to_string()));
assert_eq!(arr[2], toml::Value::String("GitHub".to_string()));
} else {
panic!("names should be an array");
}
}
#[test]
fn test_linter_check_with_md060_config() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"enabled": true,
"style": "aligned"
}),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "# Heading\n\n| a | b |\n|---|---|\n|1|2|";
let result = linter.check(content);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
let has_md060 = warnings.iter().any(|w| w["rule_name"] == "MD060");
assert!(has_md060, "Should have MD060 warning for unaligned table");
}
#[test]
fn test_linter_fix_with_rule_config() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"enabled": true,
"style": "compact"
}),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "# Heading\n\n| a | b |\n|---|---|\n| 1 | 2 |";
let fixed = linter.fix(content);
assert!(
fixed.contains("|a|b|") || fixed.contains("| a | b |"),
"Table should be formatted according to MD060 config"
);
}
#[test]
fn test_linter_config_empty_rules() {
let config = LinterConfig {
rules: Some(std::collections::HashMap::new()),
..Default::default()
};
let internal = config.to_config();
assert!(internal.rules.is_empty());
}
#[test]
fn test_linter_config_no_rules() {
let config = LinterConfig {
rules: None,
..Default::default()
};
let internal = config.to_config();
assert!(internal.rules.is_empty());
}
#[test]
fn test_config_warnings_valid_config() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"enabled": true,
"style": "aligned",
"severity": "warning"
}),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let (_, warnings) = config.to_config_with_warnings();
assert!(warnings.is_empty(), "Valid config should produce no warnings");
}
#[test]
fn test_config_warnings_invalid_severity() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"severity": "critical" }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let (internal, warnings) = config.to_config_with_warnings();
assert!(internal.rules.contains_key("MD060"));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("[MD060]"), "Warning should include rule name");
assert!(warnings[0].contains("severity"), "Warning should mention severity");
assert!(warnings[0].contains("critical"), "Warning should mention invalid value");
}
#[test]
fn test_config_warnings_invalid_value_type() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD013".to_string(),
serde_json::json!({
"line-length": "not-a-number" }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let (internal, warnings) = config.to_config_with_warnings();
assert!(internal.rules.contains_key("MD013"));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("[MD013]"), "Warning should include rule name");
assert!(warnings[0].contains("line-length"), "Warning should mention field name");
}
#[test]
fn test_config_warnings_multiple_rules() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({
"severity": "fatal" }),
);
rules.insert(
"MD013".to_string(),
serde_json::json!({
"severity": "bad" }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let (_, warnings) = config.to_config_with_warnings();
assert_eq!(warnings.len(), 2, "Should have warnings for both rules");
let has_md060_warning = warnings.iter().any(|w| w.contains("[MD060]"));
let has_md013_warning = warnings.iter().any(|w| w.contains("[MD013]"));
assert!(has_md060_warning, "Should have MD060 warning");
assert!(has_md013_warning, "Should have MD013 warning");
}
#[test]
fn test_linter_get_config_warnings() {
let config = LinterConfig {
rules: Some(std::collections::HashMap::new()),
..Default::default()
};
let (internal_config, _) = config.to_config_with_warnings();
let linter = Linter {
config: internal_config,
flavor: config.markdown_flavor(),
config_warnings: vec!["[MD060] Invalid severity: test".to_string()],
};
let result = linter.get_config_warnings();
let warnings: Vec<String> = serde_json::from_str(&result).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0], "[MD060] Invalid severity: test");
}
#[test]
fn test_linter_get_config_warnings_empty() {
let config = LinterConfig::default();
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.get_config_warnings();
let warnings: Vec<String> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_promote_opt_in_enabled_adds_to_extend_enable() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({ "enabled": true, "style": "aligned" }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
assert!(
internal.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should be promoted to extend_enable when enabled=true"
);
}
#[test]
fn test_promote_opt_in_enabled_not_added_when_disabled() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({ "enabled": false, "style": "aligned" }),
);
let config = LinterConfig {
rules: Some(rules),
..Default::default()
};
let internal = config.to_config();
assert!(
!internal.global.extend_enable.contains(&"MD060".to_string()),
"MD060 should NOT be promoted when enabled=false"
);
}
#[test]
fn test_md060_fix_applies_table_alignment() {
let mut rules = std::collections::HashMap::new();
rules.insert(
"MD060".to_string(),
serde_json::json!({ "enabled": true, "style": "aligned" }),
);
let config = LinterConfig {
disable: Some(vec!["MD041".to_string()]),
rules: Some(rules),
flavor: Some("obsidian".to_string()),
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let content = "|Column 1 |Column 2|\n|:--|--:|\n|Test|Val |\n|New|Val|\n";
let fixed = linter.fix(content);
assert_ne!(fixed, content, "MD060 fix should modify the unaligned table");
assert!(
fixed.contains("| Column 1 |"),
"Fixed table should have padded cells, got: {fixed}"
);
}
}