use indexmap::IndexMap;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use toml_edit::DocumentMut;
use super::flavor::{MarkdownFlavor, normalize_key, warn_comma_without_brace_in_pattern};
use super::source_tracking::{ConfigSource, SourcedConfigFragment, SourcedValue};
use super::types::{ConfigError, ConfigOrigin, ConfigRef, WITHHELD};
use super::validation::to_relative_display_path;
fn describe_toml_error(
content: &str,
config: ConfigRef<'_>,
span: Option<std::ops::Range<usize>>,
message: &str,
rendered: &dyn std::fmt::Display,
) -> String {
if config.may_quote_contents() {
return rendered.to_string();
}
match span {
Some(span) => {
let (line, column) = line_and_column(content, span.start);
format!("at line {line}, column {column}: {message}")
}
None => message.to_string(),
}
}
fn line_and_column(content: &str, offset: usize) -> (usize, usize) {
let mut offset = offset.min(content.len());
while offset > 0 && !content.is_char_boundary(offset) {
offset -= 1;
}
let before = &content[..offset];
let line = before.matches('\n').count() + 1;
let column = before.rsplit('\n').next().unwrap_or("").chars().count() + 1;
(line, column)
}
pub(super) fn parse_pyproject_toml(
content: &str,
path: &str,
source: ConfigSource,
origin: ConfigOrigin<'_>,
) -> Result<Option<SourcedConfigFragment>, ConfigError> {
let name = origin.display_name(path);
let display_path = ConfigRef::new(&name, origin);
let doc: toml::Value = toml::from_str(content).map_err(|e| {
let detail = describe_toml_error(content, display_path, e.span(), e.message(), &e);
ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {detail}"))
})?;
let mut fragment = SourcedConfigFragment::default();
let file = Some(path.to_string());
let registry = super::registry::default_registry();
if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
&& let Some(rumdl_table) = rumdl_config.as_table()
&& let Some(extends_val) = rumdl_table.get("extends")
&& let Ok(extends_str) = String::deserialize(extends_val.clone())
{
fragment.extends = Some(extends_str);
}
if let Some(rumdl_config) = doc.get("tool").and_then(|t| t.get("rumdl"))
&& let Some(rumdl_table) = rumdl_config.as_table()
{
let extract_global_config = |fragment: &mut SourcedConfigFragment, table: &toml::value::Table| {
use super::global_keys::{ApplyOutcome, apply_global_key};
for (key, value) in table {
let norm_key = normalize_key(key);
match apply_global_key(
&mut fragment.global,
&norm_key,
value,
source,
file.as_deref(),
registry,
) {
ApplyOutcome::Applied => {
if norm_key == "line-length" {
let rule_entry = fragment.rules.entry(normalize_key("MD013")).or_default();
let sv = rule_entry
.values
.entry(norm_key.clone())
.or_insert_with(|| SourcedValue::new(value.clone(), ConfigSource::Default));
sv.push_override(value.clone(), source, file.clone());
}
}
ApplyOutcome::TypeMismatch { expected } => {
log::warn!("[WARN] Expected {expected} for global key '{norm_key}' in {display_path}");
}
ApplyOutcome::InvalidValue { message } => {
if display_path.may_quote_contents() {
log::warn!("[WARN] {message} in {display_path}");
} else {
log::warn!("[WARN] Invalid value for global key '{norm_key}' in {display_path}");
}
}
ApplyOutcome::Unrecognized => {}
}
}
};
if let Some(global_table) = rumdl_table.get("global").and_then(|g| g.as_table()) {
extract_global_config(&mut fragment, global_table);
}
extract_global_config(&mut fragment, rumdl_table);
let per_file_ignores_key = rumdl_table
.get("per-file-ignores")
.or_else(|| rumdl_table.get("per_file_ignores"));
if let Some(per_file_ignores_value) = per_file_ignores_key
&& let Some(per_file_table) = per_file_ignores_value.as_table()
{
let mut per_file_map = BTreeMap::new();
for (pattern, rules_value) in per_file_table {
warn_comma_without_brace_in_pattern(pattern, display_path);
if let Ok(rules) = Vec::<String>::deserialize(rules_value.clone()) {
let normalized_rules = rules
.into_iter()
.map(|s| registry.resolve_rule_name(&s).unwrap_or_else(|| normalize_key(&s)))
.collect();
per_file_map.insert(pattern.clone(), normalized_rules);
} else {
let pattern = display_path.quote(pattern);
if display_path.may_quote_contents() {
log::warn!(
"[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {rules_value:?}"
);
} else {
log::warn!("[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}");
}
}
}
fragment
.per_file_ignores
.push_override(per_file_map, source, file.clone());
}
let per_file_flavor_key = rumdl_table
.get("per-file-flavor")
.or_else(|| rumdl_table.get("per_file_flavor"));
if let Some(per_file_flavor_value) = per_file_flavor_key
&& let Some(per_file_table) = per_file_flavor_value.as_table()
{
let mut per_file_map = IndexMap::new();
for (pattern, flavor_value) in per_file_table {
if let Ok(flavor) = MarkdownFlavor::deserialize(flavor_value.clone()) {
per_file_map.insert(pattern.clone(), flavor);
} else {
let pattern = display_path.quote(pattern);
if display_path.may_quote_contents() {
log::warn!(
"[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {display_path}, found {flavor_value:?}. Valid values: standard, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst"
);
} else {
log::warn!(
"[WARN] Invalid flavor for per-file-flavor pattern '{pattern}' in {display_path}. Valid values: standard, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst"
);
}
}
}
fragment
.per_file_flavor
.push_override(per_file_map, source, file.clone());
}
for (key, value) in rumdl_table {
let norm_rule_key = normalize_key(key);
let is_global_key = [
"enable",
"disable",
"include",
"exclude",
"respect_gitignore",
"respect-gitignore",
"force_exclude",
"force-exclude",
"output_format",
"output-format",
"fixable",
"unfixable",
"per-file-ignores",
"per_file_ignores",
"per-file-flavor",
"per_file_flavor",
"global",
"flavor",
"cache_dir",
"cache-dir",
"cache",
"extend-enable",
"extend_enable",
"extend-disable",
"extend_disable",
"extends",
"editorconfig",
]
.contains(&norm_rule_key.as_str());
let is_line_length_global =
(norm_rule_key == "line-length" || norm_rule_key == "line_length") && !value.is_table();
if is_global_key || is_line_length_global {
continue;
}
if norm_rule_key == "rules" {
if let Some(inner_table) = value.as_table() {
for (inner_key, inner_value) in inner_table {
if let Some(resolved_inner_rule) = registry.resolve_rule_name(inner_key) {
if let Some(inner_rule_table) = inner_value.as_table() {
apply_rule_table_toml(
&resolved_inner_rule,
inner_rule_table,
&mut fragment,
source,
&file,
display_path,
);
}
} else {
fragment.unknown_keys.push((
format!("[tool.rumdl.{}]", display_path.quote(inner_key)),
String::new(),
Some(display_path.to_string()),
));
}
}
}
continue;
}
if let Some(resolved_rule_name) = registry.resolve_rule_name(key)
&& value.is_table()
&& let Some(rule_config_table) = value.as_table()
{
apply_rule_table_toml(
&resolved_rule_name,
rule_config_table,
&mut fragment,
source,
&file,
display_path,
);
} else if registry.resolve_rule_name(key).is_none() {
fragment.unknown_keys.push((
"[tool.rumdl]".to_string(),
display_path.quote(key).to_string(),
Some(display_path.to_string()),
));
}
}
}
if let Some(tool_table) = doc.get("tool").and_then(|t| t.as_table()) {
for (key, value) in tool_table {
let rule_name = key.strip_prefix("rumdl.rules.").or_else(|| key.strip_prefix("rumdl."));
if let Some(rule_name) = rule_name {
if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
if let Some(rule_table) = value.as_table() {
apply_rule_table_toml(
&resolved_rule_name,
rule_table,
&mut fragment,
source,
&file,
display_path,
);
}
} else if rule_name.to_ascii_uppercase().starts_with("MD") || rule_name.chars().any(char::is_alphabetic)
{
fragment.unknown_keys.push((
format!("[tool.rumdl.{}]", display_path.quote(rule_name)),
String::new(),
Some(display_path.to_string()),
));
}
}
}
}
if let Some(doc_table) = doc.as_table() {
for (key, value) in doc_table {
let rule_name = key
.strip_prefix("tool.rumdl.rules.")
.or_else(|| key.strip_prefix("tool.rumdl."));
if let Some(rule_name) = rule_name {
if let Some(resolved_rule_name) = registry.resolve_rule_name(rule_name) {
if let Some(rule_table) = value.as_table() {
apply_rule_table_toml(
&resolved_rule_name,
rule_table,
&mut fragment,
source,
&file,
display_path,
);
}
} else if rule_name.to_ascii_uppercase().starts_with("MD") || rule_name.chars().any(char::is_alphabetic)
{
fragment.unknown_keys.push((
format!("[tool.rumdl.{}]", display_path.quote(rule_name)),
String::new(),
Some(display_path.to_string()),
));
}
}
}
}
let has_any = fragment.extends.is_some()
|| !fragment.global.enable.value.is_empty()
|| !fragment.global.disable.value.is_empty()
|| !fragment.global.extend_enable.value.is_empty()
|| !fragment.global.extend_disable.value.is_empty()
|| !fragment.global.include.value.is_empty()
|| !fragment.global.exclude.value.is_empty()
|| !fragment.global.fixable.value.is_empty()
|| !fragment.global.unfixable.value.is_empty()
|| fragment.global.output_format.is_some()
|| fragment.global.cache_dir.is_some()
|| fragment.global.cache.source != ConfigSource::Default
|| fragment.global.flavor.source != ConfigSource::Default
|| fragment.global.respect_gitignore.source != ConfigSource::Default
|| fragment.global.force_exclude.source != ConfigSource::Default
|| fragment.global.editorconfig.source != ConfigSource::Default
|| !fragment.per_file_ignores.value.is_empty()
|| !fragment.per_file_flavor.value.is_empty()
|| !fragment.rules.is_empty();
if has_any {
withhold_unquotable_text(&mut fragment, display_path);
Ok(Some(fragment))
} else {
Ok(None)
}
}
fn withhold_unknown_options<'k>(
norm_rule_name: &str,
keys: impl Iterator<Item = &'k str>,
fragment: &mut SourcedConfigFragment,
display_path: ConfigRef<'_>,
) -> BTreeSet<String> {
if display_path.may_quote_contents() {
return BTreeSet::new();
}
let Some(valid_keys) = super::registry::default_registry().config_keys_for(norm_rule_name) else {
return BTreeSet::new();
};
let withheld: BTreeSet<String> = keys
.map(normalize_key)
.filter(|key| !valid_keys.contains(key))
.collect();
if !withheld.is_empty() {
fragment.unknown_keys.push((
format!("[{norm_rule_name}]"),
WITHHELD.to_string(),
Some(display_path.to_string()),
));
}
withheld
}
fn withhold_unquotable_text(fragment: &mut SourcedConfigFragment, display_path: ConfigRef<'_>) {
if display_path.may_quote_contents() {
return;
}
for list in [
&mut fragment.global.enable,
&mut fragment.global.disable,
&mut fragment.global.extend_enable,
&mut fragment.global.extend_disable,
&mut fragment.global.fixable,
&mut fragment.global.unfixable,
] {
let before = list.value.len();
list.value.retain(|name| super::registry::is_valid_rule_name(name));
if list.value.len() != before {
list.value.push(WITHHELD.to_string());
}
}
fragment.per_file_ignores.value.retain(|pattern, _| {
let compiles = globset::Glob::new(&crate::discovery::expand_home_prefix(pattern)).is_ok();
if !compiles {
log::warn!("Invalid glob pattern in per-file-ignores in {display_path}: {WITHHELD}");
}
compiles
});
fragment.per_file_flavor.value.retain(|pattern, _| {
let compiles = globset::GlobBuilder::new(&crate::discovery::expand_home_prefix(pattern))
.literal_separator(true)
.build()
.is_ok();
if !compiles {
log::warn!("Invalid glob pattern in per-file-flavor in {display_path}: {WITHHELD}");
}
compiles
});
let mut withheld_patterns = Vec::new();
fragment.global.exclude.value.retain(|pattern| {
let compiles = crate::discovery::exclude_pattern_compiles(pattern);
if !compiles {
withheld_patterns.push(format!("Invalid exclude pattern in {display_path}: {WITHHELD}"));
}
compiles
});
fragment.load_warnings.extend(withheld_patterns);
if fragment.global.include.source != ConfigSource::Default {
fragment.global.include_withheld = Some(display_path.to_string());
}
for rule in fragment.rules.values_mut() {
rule.withheld_keys = rule.values.keys().cloned().collect();
}
if fragment.code_block_tools.source != ConfigSource::Default {
fragment.code_block_tools.value.values_withheld = true;
}
}
fn apply_rule_table_toml(
norm_rule_name: &str,
rule_config_table: &toml::value::Table,
fragment: &mut SourcedConfigFragment,
source: ConfigSource,
file: &Option<String>,
display_path: ConfigRef<'_>,
) {
let withheld = withhold_unknown_options(
norm_rule_name,
rule_config_table.keys().map(String::as_str),
fragment,
display_path,
);
let rule_entry = fragment.rules.entry(norm_rule_name.to_string()).or_default();
for (rk, rv) in rule_config_table {
let norm_rk = normalize_key(rk);
if withheld.contains(&norm_rk) {
continue;
}
if norm_rk == "severity" {
if let Ok(severity) = crate::rule::Severity::deserialize(rv.clone()) {
if let Some(ref mut sv) = rule_entry.severity {
sv.push_override(severity, source, file.clone());
} else {
rule_entry.severity = Some(SourcedValue::new(severity, source));
}
} else if let Some(severity_str) = rv.as_str() {
let severity_str = display_path.quote(severity_str);
log::warn!(
"[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {display_path}. Valid values: error, warning"
);
}
continue;
}
let toml_val = rv.clone();
let sv = rule_entry
.values
.entry(norm_rk.clone())
.or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
sv.push_override(toml_val, source, file.clone());
}
}
pub(super) use super::global_keys::is_global_value_key;
fn parse_global_key(
norm_key: &str,
value_item: &toml_edit::Item,
fragment: &mut SourcedConfigFragment,
source: ConfigSource,
file: &Option<String>,
display_path: ConfigRef<'_>,
registry: &super::registry::RuleRegistry,
) -> bool {
use super::global_keys::{ApplyOutcome, apply_global_key};
if !super::global_keys::is_global_value_key(norm_key) {
return false;
}
let Some(edit_value) = value_item.as_value() else {
log::warn!(
"[WARN] Expected a value for global key '{}' in {}, found {}",
norm_key,
display_path,
value_item.type_name()
);
return true;
};
let value = toml_edit_value_to_toml(edit_value);
match apply_global_key(
&mut fragment.global,
norm_key,
&value,
source,
file.as_deref(),
registry,
) {
ApplyOutcome::Applied => {}
ApplyOutcome::TypeMismatch { expected } => {
log::warn!(
"[WARN] Expected {} for global key '{}' in {}, found {}",
expected,
norm_key,
display_path,
value_item.type_name()
);
}
ApplyOutcome::InvalidValue { message } => {
if display_path.may_quote_contents() {
log::warn!("[WARN] {message} in {display_path}");
} else {
log::warn!("[WARN] Invalid value for global key '{norm_key}' in {display_path}");
}
}
ApplyOutcome::Unrecognized => unreachable!("guarded by is_global_value_key above"),
}
true
}
pub(super) fn parse_rumdl_toml(
content: &str,
path: &str,
source: ConfigSource,
origin: ConfigOrigin<'_>,
) -> Result<SourcedConfigFragment, ConfigError> {
let name = origin.display_name(path);
let display_path = ConfigRef::new(&name, origin);
let doc = content.parse::<DocumentMut>().map_err(|e| {
let detail = describe_toml_error(content, display_path, e.span(), e.message(), &e);
ConfigError::ParseError(format!("{display_path}: Failed to parse TOML: {detail}"))
})?;
let mut fragment = SourcedConfigFragment::default();
let file = Some(path.to_string());
if let Some(extends_item) = doc.get("extends")
&& let Some(extends_val) = extends_item.as_value()
&& let Some(extends_str) = extends_val.as_str()
{
fragment.extends = Some(extends_str.to_string());
}
let registry = super::registry::default_registry();
for (key, item) in doc.iter() {
if item.is_value() {
let norm_key = normalize_key(key);
if is_global_value_key(&norm_key) {
let handled = parse_global_key(&norm_key, item, &mut fragment, source, &file, display_path, registry);
debug_assert!(
handled,
"Key '{norm_key}' is in GLOBAL_VALUE_KEYS but not handled by parse_global_key"
);
}
}
}
if let Some(global_item) = doc.get("global")
&& let Some(global_table) = global_item.as_table()
{
for (key, value_item) in global_table {
let norm_key = normalize_key(key);
if !parse_global_key(
&norm_key,
value_item,
&mut fragment,
source,
&file,
display_path,
registry,
) {
let key = display_path.quote(key);
fragment
.unknown_keys
.push(("[global]".to_string(), key.to_string(), Some(display_path.to_string())));
log::warn!("[WARN] Unknown key in [global] section of {display_path}: {key}");
}
}
}
if let Some(per_file_item) = doc.get("per-file-ignores")
&& let Some(per_file_table) = per_file_item.as_table()
{
let mut per_file_map = BTreeMap::new();
for (pattern, value_item) in per_file_table {
warn_comma_without_brace_in_pattern(pattern, display_path);
if let Some(toml_edit::Value::Array(formatted_array)) = value_item.as_value() {
let rules: Vec<String> = formatted_array
.iter()
.filter_map(|item| item.as_str())
.map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
.collect();
per_file_map.insert(pattern.to_string(), rules);
} else {
let pattern = display_path.quote(pattern);
let type_name = value_item.type_name();
log::warn!(
"[WARN] Expected array for per-file-ignores pattern '{pattern}' in {display_path}, found {type_name}"
);
}
}
fragment
.per_file_ignores
.push_override(per_file_map, source, file.clone());
}
if let Some(per_file_item) = doc.get("per-file-flavor")
&& let Some(per_file_table) = per_file_item.as_table()
{
let mut per_file_map = IndexMap::new();
for (pattern, value_item) in per_file_table {
if let Some(toml_edit::Value::String(formatted_string)) = value_item.as_value() {
let flavor_str = formatted_string.value();
match MarkdownFlavor::deserialize(toml::Value::String(flavor_str.clone())) {
Ok(flavor) => {
per_file_map.insert(pattern.to_string(), flavor);
}
Err(_) => {
let flavor_str = display_path.quote(flavor_str);
let pattern = display_path.quote(pattern);
log::warn!(
"[WARN] Invalid flavor '{flavor_str}' for pattern '{pattern}' in {display_path}. Valid values: standard, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst"
);
}
}
} else {
let pattern = display_path.quote(pattern);
let type_name = value_item.type_name();
log::warn!(
"[WARN] Expected string for per-file-flavor pattern '{pattern}' in {display_path}, found {type_name}"
);
}
}
fragment
.per_file_flavor
.push_override(per_file_map, source, file.clone());
}
if let Some(cbt_item) = doc.get("code-block-tools")
&& let Some(cbt_table) = cbt_item.as_table()
{
let mut cbt_doc = toml_edit::DocumentMut::new();
for (key, value) in cbt_table {
cbt_doc[key] = value.clone();
}
let cbt_toml_str = cbt_doc.to_string();
match toml::from_str::<crate::code_block_tools::CodeBlockToolsConfig>(&cbt_toml_str) {
Ok(cbt_config) => {
fragment
.code_block_tools
.push_override(cbt_config, source, file.clone());
}
Err(e) => {
if display_path.may_quote_contents() {
log::warn!("[WARN] Failed to parse [code-block-tools] section in {display_path}: {e}");
} else {
log::warn!("[WARN] Failed to parse [code-block-tools] section in {display_path}");
}
}
}
}
for (key, item) in doc.iter() {
if key == "global"
|| key == "per-file-ignores"
|| key == "per-file-flavor"
|| key == "code-block-tools"
|| key == "extends"
{
continue;
}
if item.is_value() {
let norm_key = normalize_key(key);
if is_global_value_key(&norm_key) {
continue;
}
}
if key == "rules" {
if let Some(rules_tbl) = item.as_table() {
for (inner_key, inner_item) in rules_tbl {
if let Some(norm_inner_rule) = registry.resolve_rule_name(inner_key) {
if let Some(inner_tbl) = inner_item.as_table() {
apply_rule_table_toml_edit(
&norm_inner_rule,
inner_tbl,
&mut fragment,
source,
&file,
display_path,
);
} else {
let inner_key = display_path.quote(inner_key);
let type_name = inner_item.type_name();
log::warn!(
"[WARN] Expected table for rule '{inner_key}' in [rules] section of {display_path}, found {type_name}; ignoring"
);
}
} else {
fragment.unknown_keys.push((
format!("[{}]", display_path.quote(inner_key)),
String::new(),
Some(display_path.to_string()),
));
}
}
}
continue;
}
let Some(norm_rule_name) = registry.resolve_rule_name(key) else {
fragment.unknown_keys.push((
format!("[{}]", display_path.quote(key)),
String::new(),
Some(display_path.to_string()),
));
continue;
};
if let Some(tbl) = item.as_table() {
apply_rule_table_toml_edit(&norm_rule_name, tbl, &mut fragment, source, &file, display_path);
} else if item.is_value() {
let key = display_path.quote(key);
log::warn!(
"[WARN] Ignoring top-level value key in {display_path}: '{key}'. Expected a table like [{key}]."
);
}
}
withhold_unquotable_text(&mut fragment, display_path);
Ok(fragment)
}
fn toml_edit_value_to_toml(value: &toml_edit::Value) -> toml::Value {
match value {
toml_edit::Value::String(s) => toml::Value::String(s.value().clone()),
toml_edit::Value::Integer(i) => toml::Value::Integer(*i.value()),
toml_edit::Value::Float(f) => toml::Value::Float(*f.value()),
toml_edit::Value::Boolean(b) => toml::Value::Boolean(*b.value()),
toml_edit::Value::Datetime(d) => toml::Value::Datetime(*d.value()),
toml_edit::Value::Array(arr) => toml::Value::Array(arr.iter().map(toml_edit_value_to_toml).collect()),
toml_edit::Value::InlineTable(t) => toml::Value::Table(
t.iter()
.map(|(k, v)| (k.to_string(), toml_edit_value_to_toml(v)))
.collect(),
),
}
}
fn rule_option_table(table: &toml_edit::Table) -> toml::value::Table {
table
.iter()
.filter_map(|(key, item)| Some((key.to_string(), rule_option_value(item)?)))
.collect()
}
fn rule_option_value(item: &toml_edit::Item) -> Option<toml::Value> {
match item {
toml_edit::Item::Value(value) => Some(toml_edit_value_to_toml(value)),
toml_edit::Item::Table(table) => Some(toml::Value::Table(rule_option_table(table))),
toml_edit::Item::ArrayOfTables(tables) => Some(toml::Value::Array(
tables
.iter()
.map(|table| toml::Value::Table(rule_option_table(table)))
.collect(),
)),
toml_edit::Item::None => None,
}
}
fn apply_rule_table_toml_edit(
norm_rule_name: &str,
tbl: &toml_edit::Table,
fragment: &mut SourcedConfigFragment,
source: ConfigSource,
file: &Option<String>,
display_path: ConfigRef<'_>,
) {
let withheld = withhold_unknown_options(norm_rule_name, tbl.iter().map(|(key, _)| key), fragment, display_path);
let rule_entry = fragment.rules.entry(norm_rule_name.to_string()).or_default();
for (rk, rv_item) in tbl {
let norm_rk = normalize_key(rk);
if withheld.contains(&norm_rk) {
continue;
}
if norm_rk == "severity" {
if let Some(toml_edit::Value::String(formatted_string)) = rv_item.as_value() {
let severity_str = formatted_string.value();
match crate::rule::Severity::deserialize(toml::Value::String(severity_str.clone())) {
Ok(severity) => {
if let Some(ref mut sv) = rule_entry.severity {
sv.push_override(severity, source, file.clone());
} else {
rule_entry.severity = Some(SourcedValue::new(severity, source));
}
}
Err(_) => {
let severity_str = display_path.quote(severity_str);
log::warn!(
"[WARN] Invalid severity '{severity_str}' for rule {norm_rule_name} in {display_path}. Valid values: error, warning"
);
}
}
}
continue; }
let maybe_toml_val = rule_option_value(rv_item);
if maybe_toml_val.is_none() {
let norm_rk = display_path.quote(&norm_rk);
log::warn!(
"[WARN] Skipping non-value item for key '{norm_rule_name}.{norm_rk}' in {display_path}. Expected simple value."
);
}
if let Some(toml_val) = maybe_toml_val {
let sv = rule_entry
.values
.entry(norm_rk.clone())
.or_insert_with(|| SourcedValue::new(toml_val.clone(), ConfigSource::Default));
sv.push_override(toml_val, source, file.clone());
}
}
}
pub(super) fn load_from_markdownlint(path: &str) -> Result<SourcedConfigFragment, ConfigError> {
let display_path = to_relative_display_path(path);
let ml_config = crate::markdownlint_config::load_markdownlint_config(path)
.map_err(|e| ConfigError::ParseError(format!("{display_path}: {e}")))?;
Ok(ml_config.map_to_sourced_rumdl_config_fragment(Some(path)))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(content: &str) -> Option<SourcedConfigFragment> {
parse_pyproject_toml(
content,
"pyproject.toml",
ConfigSource::PyprojectToml,
ConfigOrigin::Direct,
)
.unwrap()
}
#[test]
fn pyproject_with_only_flavor_is_kept() {
let fragment = parse("[tool.rumdl]\nflavor = \"mkdocs\"\n")
.expect("[tool.rumdl] with only `flavor` must not be discarded");
assert_eq!(fragment.global.flavor.value, MarkdownFlavor::MkDocs);
assert_ne!(fragment.global.flavor.source, ConfigSource::Default);
}
#[test]
fn pyproject_with_only_respect_gitignore_is_kept() {
let fragment = parse("[tool.rumdl]\nrespect-gitignore = false\n")
.expect("[tool.rumdl] with only `respect-gitignore` must not be discarded");
assert!(!fragment.global.respect_gitignore.value);
}
#[test]
fn pyproject_with_only_force_exclude_is_kept() {
let fragment = parse("[tool.rumdl]\nforce-exclude = true\n")
.expect("[tool.rumdl] with only `force-exclude` must not be discarded");
assert!(fragment.global.force_exclude.value);
}
#[test]
fn pyproject_with_only_cache_true_is_kept() {
let fragment =
parse("[tool.rumdl]\ncache = true\n").expect("[tool.rumdl] with only `cache = true` must not be discarded");
assert!(fragment.global.cache.value);
assert_ne!(fragment.global.cache.source, ConfigSource::Default);
}
#[test]
fn pyproject_with_no_rumdl_section_is_none() {
assert!(parse("[tool.black]\nline-length = 88\n").is_none());
}
fn rule_option(content: &str, rule: &str, option: &str) -> Option<toml::Value> {
let fragment = parse_rumdl_toml(
content,
".rumdl.toml",
ConfigSource::ProjectConfig,
ConfigOrigin::Direct,
)
.unwrap();
fragment
.rules
.get(rule)
.and_then(|entry| entry.values.get(option))
.map(|sourced| sourced.value.clone())
}
#[test]
fn rumdl_toml_keeps_an_inline_table_option() {
let value = rule_option(
"[MD040]\npreferred-aliases = { Shell = \"sh\" }\n",
"MD040",
"preferred-aliases",
)
.expect("an inline table option must reach the fragment");
assert_eq!(value["Shell"].as_str(), Some("sh"));
}
#[test]
fn rumdl_toml_keeps_a_sub_table_option() {
let value = rule_option(
"[MD040]\n[MD040.preferred-aliases]\nShell = \"sh\"\n",
"MD040",
"preferred-aliases",
)
.expect("a sub-table option must reach the fragment");
assert_eq!(value["Shell"].as_str(), Some("sh"));
}
#[test]
fn rumdl_toml_keeps_map_keys_as_written() {
let value = rule_option(
"[MD040]\npreferred-aliases = { \"Objective-C\" = \"objc\", JSON_with_comments = \"jsonc\" }\n",
"MD040",
"preferred-aliases",
)
.expect("an inline table option must reach the fragment");
assert_eq!(value["Objective-C"].as_str(), Some("objc"));
assert_eq!(value["JSON_with_comments"].as_str(), Some("jsonc"));
}
#[test]
fn rumdl_toml_keeps_scalar_and_array_options() {
let array = rule_option(
"[MD040]\nallowed-languages = [\"rust\", \"python\"]\n",
"MD040",
"allowed-languages",
)
.expect("an array option must reach the fragment");
assert_eq!(
array.as_array().map(Vec::len),
Some(2),
"array elements must survive conversion"
);
let scalar = rule_option("[MD013]\nline-length = 120\n", "MD013", "line-length")
.expect("a scalar option must reach the fragment");
assert_eq!(scalar.as_integer(), Some(120));
}
#[test]
fn rumdl_toml_keeps_the_type_an_option_was_written_as() {
let value = rule_option("[MD001]\nwhen = 1979-05-27T07:32:00Z\n", "MD001", "when")
.expect("a datetime option must reach the fragment");
assert!(
matches!(value, toml::Value::Datetime(_)),
"a datetime option keeps its type, got {value:?}"
);
}
#[test]
fn rumdl_toml_keeps_nested_values_inside_an_array_option() {
let value = rule_option(
"[MD040]\nallowed-languages = [[\"rust\"], { name = \"python\" }]\n",
"MD040",
"allowed-languages",
)
.expect("an array option must reach the fragment");
let items = value.as_array().expect("the option is an array");
assert_eq!(items[0].as_array().and_then(|inner| inner[0].as_str()), Some("rust"));
assert_eq!(items[1]["name"].as_str(), Some("python"));
}
}