use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use crate::config::file_source::{ConfigFileSource, InMemoryConfigFiles};
use crate::config::{Config, ConfigError, ConfigLoaded, ConfigSource, MarkdownFlavor, SourcedConfig};
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_offset_to_char_offset;
#[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,
#[serde(skip_serializing_if = "Vec::is_empty")]
additional_edits: Vec<JsFix>,
}
#[derive(Serialize)]
struct JsRange {
start: usize,
end: usize,
}
fn convert_warning_for_js(warning: &LintWarning, content: &str) -> JsWarning {
fn fix_to_js(fix: &crate::rule::Fix, content: &str) -> JsFix {
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(),
additional_edits: fix.additional_edits.iter().map(|e| fix_to_js(e, content)).collect(),
}
}
let js_fix = warning.fix.as_ref().map(|fix| fix_to_js(fix, content));
let column = warning.column;
let end_column = 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();
}
fn toml_type_name(value: &toml::Value) -> &'static str {
match value {
toml::Value::String(_) => "string",
toml::Value::Integer(_) => "integer",
toml::Value::Float(_) => "float",
toml::Value::Boolean(_) => "boolean",
toml::Value::Array(_) => "array",
toml::Value::Table(_) => "table",
toml::Value::Datetime(_) => "datetime",
}
}
fn path_matches_exclude(exclude_patterns: &[String], path: &str) -> bool {
if exclude_patterns.is_empty() {
return false;
}
let normalized = path.strip_prefix("./").unwrap_or(path);
crate::discovery::ExcludeMatchers::new(exclude_patterns).is_match(normalized)
}
#[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>>,
pub exclude: Option<Vec<String>>,
#[serde(flatten)]
pub rules: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl LinterConfig {
#[cfg(test)]
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.clone_from(disable);
}
if let Some(ref enable) = self.enable {
config.global.enable.clone_from(enable);
config.global.enable_is_explicit = true;
}
if let Some(ref extend_enable) = self.extend_enable {
config.global.extend_enable.clone_from(extend_enable);
}
if let Some(ref extend_disable) = self.extend_disable {
config.global.extend_disable.clone_from(extend_disable);
}
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.clone_from(fixable);
}
if let Some(ref unfixable) = self.unfixable {
config.global.unfixable.clone_from(unfixable);
}
if let Some(ref exclude) = self.exclude {
config.global.exclude.clone_from(exclude);
}
if let Some(ref rules) = self.rules {
if rules.contains_key("extends") {
warnings.push(
"'extends' is ignored by new Linter(...): load the config file with \
Linter.from_config_files() to follow it"
.to_string(),
);
}
let registry = crate::config::registry::default_registry();
for (rule_name, json_value) in rules {
if !is_rule_name(rule_name) {
continue;
}
let canonical = rule_name.to_ascii_uppercase();
let result = json_to_rule_config_with_warnings(json_value);
for warning in result.warnings {
warnings.push(format!("[{canonical}] {warning}"));
}
if let Some(rule_config) = result.config {
for (field, actual) in &rule_config.values {
if let Some(expected) = registry.expected_value_for(&canonical, field)
&& std::mem::discriminant(actual) != std::mem::discriminant(expected)
{
warnings.push(format!(
"[{canonical}] Invalid type for '{field}': expected {}, got {}",
toml_type_name(expected),
toml_type_name(actual),
));
}
}
config.rules.insert(canonical, rule_config);
}
}
}
config.apply_per_rule_enabled();
config.canonicalize_rule_lists();
(config, warnings)
}
fn markdown_flavor(&self) -> MarkdownFlavor {
self.flavor
.as_deref()
.and_then(|s| s.parse::<MarkdownFlavor>().ok())
.unwrap_or_default()
}
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "kebab-case", default)]
struct ConfigFilesRequest {
root: String,
files: std::collections::HashMap<String, Option<String>>,
env: std::collections::HashMap<String, String>,
home: Option<String>,
default_flavor: Option<String>,
}
enum ChainOutcome {
NeedFile(std::path::PathBuf),
Loaded {
sourced: Box<SourcedConfig<ConfigLoaded>>,
chain: Vec<String>,
},
Failed(ConfigError),
}
fn parse_request(request: JsValue) -> Result<ConfigFilesRequest, JsValue> {
let request: ConfigFilesRequest = serde_wasm_bindgen::from_value(request)
.map_err(|e| JsValue::from_str(&format!("Invalid config file request: {e}")))?;
if request.root.is_empty() {
return Err(JsValue::from_str("Invalid config file request: 'root' is required"));
}
Ok(request)
}
fn load_chain(request: ConfigFilesRequest) -> ChainOutcome {
let source = InMemoryConfigFiles::new(request.files, request.env, request.home.map(std::path::PathBuf::from));
let loaded = SourcedConfig::load_chain_from(std::path::Path::new(&request.root), &source);
if let Some(path) = source.needed() {
return ChainOutcome::NeedFile(path);
}
match loaded {
Ok(sourced) => {
let chain = sourced
.loaded_files
.iter()
.rev()
.map(|p| {
source
.canonicalize(std::path::Path::new(p))
.to_string_lossy()
.into_owned()
})
.collect();
ChainOutcome::Loaded {
sourced: Box::new(sourced),
chain,
}
}
Err(e) => ChainOutcome::Failed(e),
}
}
#[wasm_bindgen]
pub fn resolve_config_chain(request: JsValue) -> Result<String, JsValue> {
Ok(chain_status_json(parse_request(request)?))
}
fn chain_status_json(request: ConfigFilesRequest) -> String {
let result = match load_chain(request) {
ChainOutcome::NeedFile(path) => serde_json::json!({
"status": "need-file",
"path": path.to_string_lossy(),
}),
ChainOutcome::Loaded { chain, .. } => serde_json::json!({
"status": "complete",
"files": chain,
}),
ChainOutcome::Failed(e) => serde_json::json!({
"status": "error",
"message": e.to_string(),
}),
};
result.to_string()
}
fn linter_from_request(request: ConfigFilesRequest) -> Result<Linter, String> {
let default_flavor = request
.default_flavor
.as_deref()
.map(|f| {
f.parse::<MarkdownFlavor>()
.map_err(|_| format!("Invalid config file request: unknown default-flavor '{f}'"))
})
.transpose()?;
let mut sourced = match load_chain(request) {
ChainOutcome::NeedFile(path) => {
return Err(format!(
"Config file '{}' was not provided; resolve the chain with resolve_config_chain first",
path.display()
));
}
ChainOutcome::Failed(e) => return Err(e.to_string()),
ChainOutcome::Loaded { sourced, .. } => *sourced,
};
if let Some(flavor) = default_flavor
&& sourced.global.flavor.source == ConfigSource::Default
{
sourced.global.flavor.value = flavor;
}
let registry = crate::config::registry::default_registry();
let (config, validation_warnings) = sourced.validate_into(registry).map_err(|e| e.to_string())?;
let flavor = config.global.flavor;
Ok(Linter {
config,
flavor,
config_warnings: validation_warnings.into_iter().map(|w| w.message).collect(),
})
}
#[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 from_config_files(request: JsValue) -> Result<Linter, JsValue> {
linter_from_request(parse_request(request)?).map_err(|e| JsValue::from_str(&e))
}
#[allow(clippy::needless_pass_by_value)]
pub fn check(&self, content: &str, path: Option<String>) -> String {
if let Some(ref p) = path
&& path_matches_exclude(&self.config.global.exclude, p)
{
return "[]".to_string();
}
let all = all_rules(&self.config);
let rules = filter_rules(&all, &self.config.global);
let run = crate::document_run::DocumentRun::new(content, &rules, &self.config)
.config_path(path.as_deref().map(std::path::Path::new));
match run.analyze().map(|analysis| analysis.warnings) {
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}"}}]"#),
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn fix(&self, content: &str, path: Option<String>) -> String {
if let Some(ref p) = path
&& path_matches_exclude(&self.config.global.exclude, p)
{
return content.to_string();
}
let all = all_rules(&self.config);
let rules = filter_rules(&all, &self.config.global);
let run = crate::document_run::DocumentRun::new(content, &rules, &self.config)
.config_path(path.as_deref().map(std::path::Path::new));
match run.fix(10) {
Ok((fixed_content, _)) => 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::*;
use std::collections::HashMap;
fn request(root: &str, files: &[(&str, Option<&str>)]) -> ConfigFilesRequest {
ConfigFilesRequest {
root: root.to_string(),
files: files
.iter()
.map(|(p, c)| (p.to_string(), c.map(str::to_string)))
.collect(),
..Default::default()
}
}
fn status(json: &str) -> serde_json::Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn flat_config_with_extends_warns_instead_of_dropping_it() {
let config = LinterConfig {
rules: Some(HashMap::from([
("extends".to_string(), serde_json::json!("../base.rumdl.toml")),
("MD013".to_string(), serde_json::json!({"line-length": 200})),
])),
..Default::default()
};
let (config, warnings) = config.to_config_with_warnings();
assert!(
warnings
.iter()
.any(|w| w.contains("'extends' is ignored") && w.contains("from_config_files")),
"expected an extends warning, got {warnings:?}"
);
assert_eq!(
config.rules["MD013"].values["line-length"],
toml::Value::Integer(200),
"the rule options beside `extends` still apply"
);
}
#[test]
fn resolve_config_chain_walks_the_chain_one_file_at_a_time() {
let r = status(&chain_status_json(request(
".rumdl.toml",
&[(".rumdl.toml", Some("extends = \"docs/../shared/base.toml\"\n"))],
)));
assert_eq!(r["status"], "need-file");
assert_eq!(r["path"], "shared/base.toml", "asked for in normalized form");
let r = status(&chain_status_json(request(
".rumdl.toml",
&[
(".rumdl.toml", Some("extends = \"docs/../shared/base.toml\"\n")),
("shared/base.toml", Some("extends = \"org.toml\"\n")),
],
)));
assert_eq!(r["status"], "need-file");
assert_eq!(
r["path"], "shared/org.toml",
"resolved against the declaring file's directory"
);
let r = status(&chain_status_json(request(
".rumdl.toml",
&[
(".rumdl.toml", Some("extends = \"docs/../shared/base.toml\"\n")),
("shared/base.toml", Some("extends = \"org.toml\"\n")),
("shared/org.toml", Some("[global]\nline-length = 66\n")),
],
)));
assert_eq!(r["status"], "complete", "{r}");
assert_eq!(
r["files"],
serde_json::json!([".rumdl.toml", "shared/base.toml", "shared/org.toml"])
);
}
#[test]
fn resolve_config_chain_reports_errors_the_embedder_cannot_fix() {
let r = status(&chain_status_json(request(
".rumdl.toml",
&[(".rumdl.toml", Some("extends = \"base.toml\"\n")), ("base.toml", None)],
)));
assert_eq!(r["status"], "error", "{r}");
assert!(
r["message"].as_str().unwrap().contains("base.toml"),
"error names the missing file: {r}"
);
let r = status(&chain_status_json(request(
".rumdl.toml",
&[
(".rumdl.toml", Some("extends = \"a.toml\"\n")),
("a.toml", Some("extends = \".rumdl.toml\"\n")),
],
)));
assert_eq!(r["status"], "error", "{r}");
assert!(
r["message"].as_str().unwrap().to_lowercase().contains("circular"),
"{r}"
);
let r = status(&chain_status_json(request(
".rumdl.toml",
&[(".rumdl.toml", Some("[global\nline-length = 1\n"))],
)));
assert_eq!(r["status"], "error", "{r}");
}
#[test]
fn from_config_files_merges_the_chain_like_the_cli() {
let req = request(
".rumdl.toml",
&[
(
".rumdl.toml",
Some(
"extends = \"base/.rumdl.toml\"\n[global]\nextend-disable = [\"MD041\"]\n[MD013]\nline-length = 100\n",
),
),
(
"base/.rumdl.toml",
Some(
"[global]\ndisable = [\"MD033\"]\nflavor = \"mkdocs\"\n[MD013]\nline-length = 80\n[MD007]\nindent = 4\n",
),
),
],
);
let linter = linter_from_request(req).unwrap();
let config: serde_json::Value = serde_json::from_str(&linter.get_config()).unwrap();
assert_eq!(
config["rules"]["MD013"]["line-length"], 100,
"child overrides base: {config}"
);
assert_eq!(
config["rules"]["MD007"]["indent"], 4,
"base rule config inherited: {config}"
);
let disabled = config["disable"].as_array().unwrap();
assert!(
disabled.iter().any(|d| d == "MD033"),
"base disable inherited: {config}"
);
let extend_disabled = config["extend_disable"].as_array().unwrap();
assert!(
extend_disabled.iter().any(|d| d == "MD041"),
"child extend-disable applied: {config}"
);
assert_eq!(linter.flavor, MarkdownFlavor::MkDocs, "flavor set by the base applies");
assert!(
linter.get_config_warnings().contains("[]"),
"{}",
linter.get_config_warnings()
);
let long = format!("# T\n\n{}\n", "x".repeat(95));
let result = linter.check(&long, None);
assert!(
!result.contains("MD013"),
"line-length 100 from the child applies: {result}"
);
let html = "# T\n\n<b>bold</b>\n";
let result = linter.check(html, None);
assert!(!result.contains("MD033"), "MD033 disabled by the base: {result}");
let no_heading = "plain first line\n";
let result = linter.check(no_heading, None);
assert!(!result.contains("MD041"), "MD041 disabled by the child: {result}");
}
#[test]
fn from_config_files_applies_default_flavor_only_when_no_file_sets_one() {
let mut req = request(".rumdl.toml", &[(".rumdl.toml", Some("[global]\nline-length = 120\n"))]);
req.default_flavor = Some("obsidian".to_string());
let linter = linter_from_request(req).unwrap();
assert_eq!(
linter.flavor,
MarkdownFlavor::Obsidian,
"embedder default used when unset"
);
let mut req = request(
".rumdl.toml",
&[(".rumdl.toml", Some("[global]\nflavor = \"standard\"\n"))],
);
req.default_flavor = Some("obsidian".to_string());
let linter = linter_from_request(req).unwrap();
assert_eq!(
linter.flavor,
MarkdownFlavor::Standard,
"a file's flavor wins over the default"
);
let mut req = request(".rumdl.toml", &[(".rumdl.toml", Some(""))]);
req.default_flavor = Some("not-a-flavor".to_string());
let err = linter_from_request(req)
.err()
.expect("unknown default-flavor is rejected");
assert!(err.contains("default-flavor"), "{err}");
}
#[test]
fn from_config_files_rejects_an_unresolved_chain() {
let req = request(".rumdl.toml", &[(".rumdl.toml", Some("extends = \"base.toml\"\n"))]);
let err = linter_from_request(req).err().expect("an unresolved chain is rejected");
assert!(
err.contains("base.toml") && err.contains("resolve_config_chain"),
"{err}"
);
}
#[test]
fn from_config_files_surfaces_validation_warnings() {
let req = request(
".rumdl.toml",
&[(".rumdl.toml", Some("[MD013]\nline-length = 100\nnot-an-option = 1\n"))],
);
let linter = linter_from_request(req).unwrap();
let warnings = linter.get_config_warnings();
assert!(
warnings.contains("not-an-option"),
"unknown rule option reported: {warnings}"
);
}
#[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_per_file_ignores_honored_by_check_and_fix() {
let mut config = crate::config::Config::default();
config
.per_file_ignores
.insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
config.canonicalize_rule_lists();
let linter = Linter {
config,
flavor: MarkdownFlavor::Standard,
config_warnings: vec![],
};
let content = "# Title\n\ntext\n- parent\n * child\n";
let json = linter.check(content, Some("slides/deck.md".to_string()));
assert!(json.contains("MD032"), "MD032 should be reported: {json}");
assert!(
!json.contains("MD004"),
"MD004 is per-file-ignored for slides/** and must not be reported: {json}"
);
let fixed = linter.fix(content, Some("slides/deck.md".to_string()));
assert_eq!(
fixed, "# Title\n\ntext\n\n- parent\n * child\n",
"fix must apply only the non-ignored MD032 fix, preserving the MD004 marker"
);
let json_other = linter.check(content, Some("docs/other.md".to_string()));
assert!(
json_other.contains("MD004"),
"MD004 should be reported when not ignored: {json_other}"
);
}
#[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("pandoc".to_string()),
..Default::default()
}
.markdown_flavor(),
MarkdownFlavor::Pandoc
);
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::Pandoc,
MarkdownFlavor::Quarto,
MarkdownFlavor::Obsidian,
MarkdownFlavor::Kramdown,
MarkdownFlavor::AzureDevOps,
MarkdownFlavor::MyST,
MarkdownFlavor::Hugo,
];
for flavor in flavors {
let flavor_str = match flavor {
MarkdownFlavor::Standard => "standard",
MarkdownFlavor::MkDocs => "mkdocs",
MarkdownFlavor::MDX => "mdx",
MarkdownFlavor::Pandoc => "pandoc",
MarkdownFlavor::Quarto => "quarto",
MarkdownFlavor::Obsidian => "obsidian",
MarkdownFlavor::Kramdown => "kramdown",
MarkdownFlavor::AzureDevOps => "azure_devops",
MarkdownFlavor::MyST => "myst",
MarkdownFlavor::Hugo => "hugo",
};
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("", None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
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, None);
assert_ne!(fixed, content, "MD060 fix should modify the unaligned table");
assert!(
fixed.contains("| Column 1 |"),
"Fixed table should have padded cells, got: {fixed}"
);
}
fn exclude_linter(patterns: Vec<&str>) -> Linter {
let config = LinterConfig {
exclude: Some(patterns.into_iter().map(String::from).collect()),
..Default::default()
};
Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
}
}
const LINT_TRIGGERING: &str = "## Level 2\n\n#### Level 4";
#[test]
fn test_linter_check_no_path_lints_even_with_exclude() {
let linter = exclude_linter(vec!["q2/**/*.md"]);
let result = linter.check(LINT_TRIGGERING, None);
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty(), "No path → should still lint");
}
#[test]
fn test_linter_check_glob_excludes_nested_file() {
let linter = exclude_linter(vec!["q2/**/*.md"]);
let result = linter.check(LINT_TRIGGERING, Some("q2/sub/page.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty(), "Glob match should exclude file");
}
#[test]
fn test_linter_check_bare_directory_excludes_contents() {
let linter = exclude_linter(vec![".git", "node_modules"]);
for path in [".git/config.md", "node_modules/pkg/README.md"] {
let result = linter.check(LINT_TRIGGERING, Some(path.to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty(), "{path} under excluded dir should be skipped");
}
}
#[test]
fn test_linter_check_bare_filename_matches_root() {
let linter = exclude_linter(vec!["CHANGELOG.md"]);
let result = linter.check(LINT_TRIGGERING, Some("CHANGELOG.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty(), "Bare filename match should exclude");
}
#[test]
fn test_linter_check_non_matching_path_still_lints() {
let linter = exclude_linter(vec!["q2/**/*.md", ".git"]);
let result = linter.check(LINT_TRIGGERING, Some("notes/foo.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty(), "Non-matching path should still lint");
}
#[test]
fn test_linter_check_path_with_leading_dot_slash() {
let linter = exclude_linter(vec!["q2/**/*.md"]);
let result = linter.check(LINT_TRIGGERING, Some("./q2/sub/page.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(warnings.is_empty(), "`./` prefix should be normalized");
}
#[test]
fn test_linter_fix_excluded_returns_unchanged() {
let content = "Hello \nWorld";
let linter = exclude_linter(vec!["q2/**/*.md"]);
let fixed = linter.fix(content, Some("q2/sub/page.md".to_string()));
assert_eq!(fixed, content, "Excluded file → fix returns content unchanged");
}
#[test]
fn test_linter_fix_non_excluded_still_fixes() {
let content = "Hello \nWorld";
let linter = exclude_linter(vec!["q2/**/*.md"]);
let fixed = linter.fix(content, Some("notes/foo.md".to_string()));
assert!(!fixed.contains(" \n"), "Non-excluded path should still get fixed");
}
#[test]
fn test_linter_config_deserializes_exclude() {
let json = serde_json::json!({
"exclude": ["q2/**/*.md", ".git", "CHANGELOG.md"],
});
let config: LinterConfig = serde_json::from_value(json).unwrap();
assert_eq!(
config.exclude.as_deref(),
Some(&["q2/**/*.md".to_string(), ".git".to_string(), "CHANGELOG.md".to_string()][..])
);
}
#[test]
fn test_linter_exclude_wired_to_global_config() {
let config = LinterConfig {
exclude: Some(vec!["docs/**".to_string()]),
..Default::default()
};
let internal = config.to_config();
assert_eq!(internal.global.exclude, vec!["docs/**".to_string()]);
}
#[test]
fn test_linter_check_empty_exclude_array_lints_normally() {
let linter = exclude_linter(vec![]);
let result = linter.check(LINT_TRIGGERING, Some("q2/page.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty(), "Empty exclude array should not skip linting");
}
#[test]
fn test_linter_check_none_exclude_lints_normally() {
let config = LinterConfig {
exclude: None,
..Default::default()
};
let linter = Linter {
config: config.to_config(),
flavor: config.markdown_flavor(),
config_warnings: Vec::new(),
};
let result = linter.check(LINT_TRIGGERING, Some("q2/page.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty(), "None exclude should not skip linting");
}
#[test]
fn test_linter_check_exclude_is_case_sensitive() {
let linter = exclude_linter(vec!["README.md"]);
let result = linter.check(LINT_TRIGGERING, Some("readme.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(!warnings.is_empty(), "Case mismatch should not exclude");
}
#[test]
fn test_linter_check_single_char_wildcard() {
let linter = exclude_linter(vec!["draft?.md"]);
let cases = [("draft1.md", true), ("draft.md", false), ("draft10.md", false)];
for (path, should_exclude) in cases {
let result = linter.check(LINT_TRIGGERING, Some(path.to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert_eq!(warnings.is_empty(), should_exclude, "`?` wildcard mismatch for {path}");
}
}
#[test]
fn test_linter_check_character_class_glob() {
let linter = exclude_linter(vec!["draft[12].md"]);
let cases = [("draft1.md", true), ("draft2.md", true), ("draft3.md", false)];
for (path, should_exclude) in cases {
let result = linter.check(LINT_TRIGGERING, Some(path.to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert_eq!(
warnings.is_empty(),
should_exclude,
"Character class mismatch for {path}"
);
}
}
#[test]
fn test_linter_check_invalid_glob_pattern_falls_back_to_lint() {
let linter = exclude_linter(vec!["[invalid"]);
let result = linter.check(LINT_TRIGGERING, Some("any.md".to_string()));
let warnings: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(
!warnings.is_empty(),
"Invalid pattern should be ignored, file still lints"
);
}
#[test]
fn test_linter_fix_empty_exclude_fixes_everything() {
let content = "Hello \nWorld";
let linter = exclude_linter(vec![]);
let fixed = linter.fix(content, Some("q2/page.md".to_string()));
assert!(!fixed.contains(" \n"), "Empty exclude → fix should run");
}
}