use std::collections::HashMap;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use globset::GlobBuilder;
use serde::{Deserialize, Deserializer, Serialize};
mod formatter_presets;
pub use formatter_presets::FormatterPresetMetadata;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Flavor {
#[default]
Pandoc,
Quarto,
#[serde(rename = "rmarkdown")]
RMarkdown,
Gfm,
CommonMark,
#[serde(rename = "multimarkdown")]
MultiMarkdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct Extensions {
#[serde(alias = "blank_before_header")]
pub blank_before_header: bool,
#[serde(alias = "header_attributes")]
pub header_attributes: bool,
pub auto_identifiers: bool,
pub gfm_auto_identifiers: bool,
pub implicit_header_references: bool,
#[serde(alias = "blank_before_blockquote")]
pub blank_before_blockquote: bool,
#[serde(alias = "fancy_lists")]
pub fancy_lists: bool,
pub startnum: bool,
#[serde(alias = "example_lists")]
pub example_lists: bool,
#[serde(alias = "task_lists")]
pub task_lists: bool,
#[serde(alias = "definition_lists")]
pub definition_lists: bool,
#[serde(alias = "backtick_code_blocks")]
pub backtick_code_blocks: bool,
#[serde(alias = "fenced_code_blocks")]
pub fenced_code_blocks: bool,
#[serde(alias = "fenced_code_attributes")]
pub fenced_code_attributes: bool,
pub executable_code: bool,
pub rmarkdown_inline_code: bool,
pub quarto_inline_code: bool,
#[serde(alias = "inline_code_attributes")]
pub inline_code_attributes: bool,
#[serde(alias = "simple_tables")]
pub simple_tables: bool,
#[serde(alias = "multiline_tables")]
pub multiline_tables: bool,
#[serde(alias = "grid_tables")]
pub grid_tables: bool,
#[serde(alias = "pipe_tables")]
pub pipe_tables: bool,
#[serde(alias = "table_captions")]
pub table_captions: bool,
#[serde(alias = "fenced_divs")]
pub fenced_divs: bool,
#[serde(alias = "native_divs")]
pub native_divs: bool,
#[serde(alias = "line_blocks")]
pub line_blocks: bool,
#[serde(alias = "intraword_underscores")]
pub intraword_underscores: bool,
pub strikeout: bool,
pub superscript: bool,
pub subscript: bool,
#[serde(alias = "inline_links")]
pub inline_links: bool,
#[serde(alias = "reference_links")]
pub reference_links: bool,
#[serde(alias = "shortcut_reference_links")]
pub shortcut_reference_links: bool,
#[serde(alias = "link_attributes")]
pub link_attributes: bool,
pub autolinks: bool,
#[serde(alias = "inline_images")]
pub inline_images: bool,
#[serde(alias = "implicit_figures")]
pub implicit_figures: bool,
#[serde(alias = "tex_math_dollars")]
pub tex_math_dollars: bool,
#[serde(alias = "tex_math_gfm")]
pub tex_math_gfm: bool,
#[serde(alias = "tex_math_single_backslash")]
pub tex_math_single_backslash: bool,
#[serde(alias = "tex_math_double_backslash")]
pub tex_math_double_backslash: bool,
#[serde(alias = "inline_footnotes")]
pub inline_footnotes: bool,
pub footnotes: bool,
pub citations: bool,
#[serde(alias = "bracketed_spans")]
pub bracketed_spans: bool,
#[serde(alias = "native_spans")]
pub native_spans: bool,
#[serde(alias = "yaml_metadata_block")]
pub yaml_metadata_block: bool,
#[serde(alias = "pandoc_title_block")]
pub pandoc_title_block: bool,
pub mmd_title_block: bool,
#[serde(alias = "raw_html")]
pub raw_html: bool,
#[serde(alias = "markdown_in_html_blocks")]
pub markdown_in_html_blocks: bool,
#[serde(alias = "raw_tex")]
pub raw_tex: bool,
#[serde(alias = "raw_attribute")]
pub raw_attribute: bool,
#[serde(alias = "all_symbols_escapable")]
pub all_symbols_escapable: bool,
#[serde(alias = "escaped_line_breaks")]
pub escaped_line_breaks: bool,
#[serde(alias = "autolink_bare_uris")]
pub autolink_bare_uris: bool,
#[serde(alias = "hard_line_breaks")]
pub hard_line_breaks: bool,
pub mmd_header_identifiers: bool,
pub mmd_link_attributes: bool,
pub alerts: bool,
pub emoji: bool,
pub mark: bool,
#[serde(alias = "quarto_callouts")]
pub quarto_callouts: bool,
#[serde(alias = "quarto_crossrefs")]
pub quarto_crossrefs: bool,
#[serde(alias = "quarto_shortcodes")]
pub quarto_shortcodes: bool,
pub bookdown_references: bool,
pub bookdown_equation_references: bool,
}
impl Default for Extensions {
fn default() -> Self {
Self::for_flavor(Flavor::default())
}
}
impl Extensions {
fn none_defaults() -> Self {
Self {
alerts: false,
all_symbols_escapable: false,
auto_identifiers: false,
autolink_bare_uris: false,
autolinks: false,
backtick_code_blocks: false,
blank_before_blockquote: false,
blank_before_header: false,
bookdown_references: false,
bookdown_equation_references: false,
bracketed_spans: false,
citations: false,
definition_lists: false,
emoji: false,
escaped_line_breaks: false,
example_lists: false,
executable_code: false,
rmarkdown_inline_code: false,
quarto_inline_code: false,
fancy_lists: false,
fenced_code_attributes: false,
fenced_code_blocks: false,
fenced_divs: false,
footnotes: false,
gfm_auto_identifiers: false,
grid_tables: false,
hard_line_breaks: false,
header_attributes: false,
implicit_figures: false,
implicit_header_references: false,
inline_code_attributes: false,
inline_footnotes: false,
inline_images: false,
inline_links: false,
intraword_underscores: false,
line_blocks: false,
link_attributes: false,
mark: false,
markdown_in_html_blocks: false,
mmd_header_identifiers: false,
mmd_link_attributes: false,
mmd_title_block: false,
multiline_tables: false,
native_divs: false,
native_spans: false,
pandoc_title_block: false,
pipe_tables: false,
quarto_callouts: false,
quarto_crossrefs: false,
quarto_shortcodes: false,
raw_attribute: false,
raw_html: false,
raw_tex: false,
reference_links: false,
shortcut_reference_links: false,
simple_tables: false,
startnum: false,
strikeout: false,
subscript: false,
superscript: false,
table_captions: false,
task_lists: false,
tex_math_dollars: false,
tex_math_double_backslash: false,
tex_math_gfm: false,
tex_math_single_backslash: false,
yaml_metadata_block: false,
}
}
pub fn for_flavor(flavor: Flavor) -> Self {
match flavor {
Flavor::Pandoc => Self::pandoc_defaults(),
Flavor::Quarto => Self::quarto_defaults(),
Flavor::RMarkdown => Self::rmarkdown_defaults(),
Flavor::Gfm => Self::gfm_defaults(),
Flavor::CommonMark => Self::commonmark_defaults(),
Flavor::MultiMarkdown => Self::multimarkdown_defaults(),
}
}
fn pandoc_defaults() -> Self {
Self {
auto_identifiers: true,
blank_before_blockquote: true,
blank_before_header: true,
gfm_auto_identifiers: false,
header_attributes: true,
implicit_header_references: true,
definition_lists: true,
example_lists: true,
fancy_lists: true,
startnum: true,
task_lists: true,
backtick_code_blocks: true,
executable_code: false,
rmarkdown_inline_code: false,
quarto_inline_code: false,
fenced_code_attributes: true,
fenced_code_blocks: true,
inline_code_attributes: true,
grid_tables: true,
multiline_tables: true,
pipe_tables: true,
simple_tables: true,
table_captions: true,
fenced_divs: true,
native_divs: true,
line_blocks: true,
intraword_underscores: true,
strikeout: true,
subscript: true,
superscript: true,
autolinks: true,
inline_links: true,
link_attributes: true,
reference_links: true,
shortcut_reference_links: true,
implicit_figures: true,
inline_images: true,
tex_math_dollars: true,
tex_math_double_backslash: false,
tex_math_gfm: false,
tex_math_single_backslash: false,
footnotes: true,
inline_footnotes: true,
citations: true,
bracketed_spans: true,
native_spans: true,
mmd_title_block: false,
pandoc_title_block: true,
yaml_metadata_block: true,
markdown_in_html_blocks: false,
raw_attribute: true,
raw_html: true,
raw_tex: true,
all_symbols_escapable: true,
escaped_line_breaks: true,
alerts: false,
autolink_bare_uris: false,
emoji: false,
hard_line_breaks: false,
mark: false,
mmd_header_identifiers: false,
mmd_link_attributes: false,
bookdown_references: false,
bookdown_equation_references: false,
quarto_callouts: false,
quarto_crossrefs: false,
quarto_shortcodes: false,
}
}
fn quarto_defaults() -> Self {
let mut ext = Self::pandoc_defaults();
ext.executable_code = true;
ext.rmarkdown_inline_code = true;
ext.quarto_inline_code = true;
ext.quarto_callouts = true;
ext.quarto_crossrefs = true;
ext.quarto_shortcodes = true;
ext
}
fn rmarkdown_defaults() -> Self {
let mut ext = Self::pandoc_defaults();
ext.bookdown_references = true;
ext.bookdown_equation_references = true;
ext.executable_code = true;
ext.rmarkdown_inline_code = true;
ext.quarto_inline_code = false;
ext.tex_math_dollars = true;
ext.tex_math_single_backslash = true;
ext
}
fn gfm_defaults() -> Self {
let mut ext = Self::none_defaults();
ext.alerts = true;
ext.auto_identifiers = true;
ext.autolink_bare_uris = true;
ext.backtick_code_blocks = true;
ext.emoji = true;
ext.fenced_code_blocks = true;
ext.footnotes = true;
ext.gfm_auto_identifiers = true;
ext.pipe_tables = true;
ext.raw_html = true;
ext.strikeout = true;
ext.task_lists = true;
ext.tex_math_dollars = true;
ext.tex_math_gfm = true;
ext.yaml_metadata_block = true;
ext
}
fn commonmark_defaults() -> Self {
let mut ext = Self::none_defaults();
ext.raw_html = true;
ext
}
fn multimarkdown_defaults() -> Self {
let mut ext = Self::none_defaults();
ext.all_symbols_escapable = true;
ext.auto_identifiers = true;
ext.backtick_code_blocks = true;
ext.definition_lists = true;
ext.footnotes = true;
ext.implicit_figures = true;
ext.implicit_header_references = true;
ext.intraword_underscores = true;
ext.mmd_header_identifiers = true;
ext.mmd_link_attributes = true;
ext.mmd_title_block = true;
ext.pipe_tables = true;
ext.raw_attribute = true;
ext.raw_html = true;
ext.reference_links = true;
ext.shortcut_reference_links = true;
ext.subscript = true;
ext.superscript = true;
ext.tex_math_dollars = true;
ext.tex_math_double_backslash = true;
ext
}
pub fn merge_with_flavor(user_overrides: HashMap<String, bool>, flavor: Flavor) -> Self {
let defaults = Self::for_flavor(flavor);
Self::merge_overrides(defaults, user_overrides)
}
fn merge_overrides(base: Extensions, user_overrides: HashMap<String, bool>) -> Self {
use serde_json::{Map, Value};
let defaults_value =
serde_json::to_value(&base).expect("Failed to serialize extension defaults");
let mut merged = if let Value::Object(obj) = defaults_value {
obj
} else {
Map::new()
};
for (key, value) in user_overrides {
let normalized_key = key.replace('_', "-");
merged.insert(normalized_key, Value::Bool(value));
}
serde_json::from_value(Value::Object(merged))
.expect("Failed to deserialize merged extensions")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormatterConfig {
pub cmd: String,
pub args: Vec<String>,
pub enabled: bool,
pub stdin: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum FormatterValue {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct FormatterDefinition {
pub preset: Option<String>,
pub cmd: Option<String>,
pub args: Option<Vec<String>>,
#[serde(alias = "prepend_args")]
pub prepend_args: Option<Vec<String>>,
#[serde(alias = "append_args")]
pub append_args: Option<Vec<String>>,
pub stdin: Option<bool>,
pub enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct RawFormatterConfig {
preset: Option<String>,
cmd: Option<String>,
args: Option<Vec<String>>,
enabled: bool,
stdin: bool,
}
impl Default for RawFormatterConfig {
fn default() -> Self {
Self {
preset: None,
cmd: None,
args: None,
enabled: true,
stdin: true,
}
}
}
impl<'de> Deserialize<'de> for FormatterConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = RawFormatterConfig::deserialize(deserializer)?;
if raw.preset.is_some() && raw.cmd.is_some() {
return Err(serde::de::Error::custom(
"FormatterConfig: 'preset' and 'cmd' are mutually exclusive - use one or the other",
));
}
if let Some(preset_name) = raw.preset {
let preset = get_formatter_preset(&preset_name).ok_or_else(|| {
let available = formatter_preset_names().join(", ");
serde::de::Error::custom(format!(
"Unknown formatter preset: '{}'. Available presets: {}",
preset_name, available
))
})?;
Ok(FormatterConfig {
cmd: preset.cmd,
args: preset.args,
enabled: raw.enabled,
stdin: preset.stdin,
})
} else if let Some(cmd) = raw.cmd {
Ok(FormatterConfig {
cmd,
args: raw.args.unwrap_or_default(),
enabled: raw.enabled,
stdin: raw.stdin,
})
} else {
Ok(FormatterConfig {
cmd: String::new(),
args: raw.args.unwrap_or_default(),
enabled: raw.enabled,
stdin: raw.stdin,
})
}
}
}
impl Default for FormatterConfig {
fn default() -> Self {
Self {
cmd: String::new(),
args: Vec::new(),
enabled: true,
stdin: true,
}
}
}
pub fn get_formatter_preset(name: &str) -> Option<FormatterConfig> {
formatter_presets::get_formatter_preset(name)
}
pub fn formatter_preset_names() -> &'static [&'static str] {
formatter_presets::formatter_preset_names()
}
pub fn formatter_preset_supported_languages(name: &str) -> Option<&'static [&'static str]> {
formatter_presets::formatter_preset_supported_languages(name)
}
pub fn formatter_preset_metadata(name: &str) -> Option<&'static FormatterPresetMetadata> {
formatter_presets::formatter_preset_metadata(name)
}
pub fn all_formatter_preset_metadata() -> &'static [FormatterPresetMetadata] {
formatter_presets::all_formatter_preset_metadata()
}
pub fn formatter_presets_for_language(language: &str) -> Vec<&'static FormatterPresetMetadata> {
formatter_presets::formatter_presets_for_language(language)
}
fn normalize_formatter_language(language: &str) -> String {
language.trim().to_ascii_lowercase().replace('_', "-")
}
fn validate_formatter_language_for_preset(lang: &str, formatter_name: &str) -> Result<(), String> {
let Some(supported) = formatter_preset_supported_languages(formatter_name) else {
return Ok(()); };
let normalized_lang = normalize_formatter_language(lang);
let matches = supported
.iter()
.any(|supported_lang| *supported_lang == normalized_lang);
if matches {
return Ok(());
}
Err(format!(
"Language '{}': formatter '{}' does not support this language. Supported languages: {}",
lang,
formatter_name,
supported.join(", ")
))
}
pub fn default_formatters() -> HashMap<String, FormatterConfig> {
let mut map = HashMap::new();
map.insert("r".to_string(), get_formatter_preset("air").unwrap());
map.insert("python".to_string(), get_formatter_preset("ruff").unwrap());
map
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MathDelimiterStyle {
#[default]
Preserve,
Dollars,
Backslash,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TabStopMode {
#[default]
Normalize,
Preserve,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(default)]
#[serde(rename_all = "kebab-case")]
pub struct StyleConfig {
pub wrap: Option<WrapMode>,
pub blank_lines: BlankLines,
pub math_delimiter_style: MathDelimiterStyle,
pub math_indent: usize,
pub tab_stops: TabStopMode,
pub tab_width: usize,
pub built_in_greedy_wrap: bool,
}
impl Default for StyleConfig {
fn default() -> Self {
Self {
wrap: Some(WrapMode::Reflow),
blank_lines: BlankLines::Collapse,
math_delimiter_style: MathDelimiterStyle::default(),
math_indent: 0,
tab_stops: TabStopMode::Normalize,
tab_width: 4,
built_in_greedy_wrap: true,
}
}
}
impl StyleConfig {
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum PandocCompat {
#[serde(rename = "latest")]
Latest,
#[serde(rename = "3.7", alias = "3-7", alias = "v3.7", alias = "v3-7")]
V3_7,
#[default]
#[serde(rename = "3.9", alias = "3-9", alias = "v3.9", alias = "v3-9")]
V3_9,
}
impl PandocCompat {
pub const PINNED_LATEST: Self = Self::V3_9;
pub fn effective(self) -> Self {
match self {
Self::Latest => Self::PINNED_LATEST,
other => other,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default, rename_all = "kebab-case")]
pub struct ParserConfig {
pub pandoc_compat: PandocCompat,
}
impl ParserConfig {
pub fn effective_pandoc_compat(&self) -> PandocCompat {
self.pandoc_compat.effective()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Default)]
pub struct LintConfig {
pub rules: HashMap<String, bool>,
}
impl LintConfig {
fn normalize_rule_name(name: &str) -> String {
name.trim().to_lowercase().replace('_', "-")
}
fn normalize(mut self) -> Self {
self.rules = self
.rules
.into_iter()
.map(|(name, enabled)| (Self::normalize_rule_name(&name), enabled))
.collect();
self
}
pub fn is_rule_enabled(&self, rule_name: &str) -> bool {
let normalized = Self::normalize_rule_name(rule_name);
self.rules.get(&normalized).copied().unwrap_or(true)
}
}
impl<'de> Deserialize<'de> for LintConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
let mut rules = HashMap::new();
let mut used_legacy_shape = false;
let mut table = value
.as_table()
.cloned()
.ok_or_else(|| serde::de::Error::custom("expected [lint] table"))?;
if let Some(rules_value) = table.remove("rules") {
let rules_table = rules_value
.as_table()
.ok_or_else(|| serde::de::Error::custom("[lint.rules] must be a table"))?;
for (name, enabled) in rules_table {
let enabled = enabled.as_bool().ok_or_else(|| {
serde::de::Error::custom(format!(
"[lint.rules] entry '{}' must be true or false",
name
))
})?;
rules.insert(name.clone(), enabled);
}
}
for (name, enabled) in table {
let enabled = enabled.as_bool().ok_or_else(|| {
serde::de::Error::custom(format!(
"Unsupported [lint] key '{}'; use [lint.rules] for rule toggles",
name
))
})?;
used_legacy_shape = true;
rules.insert(name, enabled);
}
if used_legacy_shape {
eprintln!(
"Warning: [lint] rule = true/false is deprecated; use [lint.rules] rule = true/false."
);
}
Ok(Self { rules }.normalize())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RawConfig {
#[serde(default)]
flavor: Flavor,
#[serde(default)]
extensions: Option<toml::Value>,
#[serde(default)]
line_ending: Option<LineEnding>,
#[serde(default = "default_line_width")]
line_width: usize,
#[serde(default)]
pandoc_compat: Option<PandocCompat>,
#[serde(default)]
#[serde(rename = "format")]
format_section: Option<StyleConfig>,
#[serde(default)]
style: Option<StyleConfig>,
#[serde(default)]
math_indent: usize,
#[serde(default)]
math_delimiter_style: MathDelimiterStyle,
#[serde(default)]
wrap: Option<WrapMode>,
#[serde(default = "default_blank_lines")]
blank_lines: BlankLines,
#[serde(default)]
tab_stops: TabStopMode,
#[serde(default = "default_tab_width")]
tab_width: usize,
#[serde(default)]
parser: Option<ParserConfig>,
#[serde(default)]
formatters: Option<toml::Value>,
#[serde(default)]
external_max_parallel: Option<usize>,
#[serde(default)]
linters: HashMap<String, String>,
#[serde(default)]
lint: Option<LintConfig>,
#[serde(default)]
cache_dir: Option<String>,
#[serde(default)]
exclude: Option<Vec<String>>,
#[serde(default)]
extend_exclude: Vec<String>,
#[serde(default)]
include: Option<Vec<String>>,
#[serde(default)]
extend_include: Vec<String>,
#[serde(default)]
flavor_overrides: HashMap<String, Flavor>,
}
fn default_line_width() -> usize {
80
}
fn default_external_max_parallel() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.clamp(1, 8)
}
fn default_blank_lines() -> BlankLines {
BlankLines::Collapse
}
fn default_tab_width() -> usize {
4
}
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
".Rproj.user/",
".bzr/",
".cache/",
".devevn/",
".direnv/",
".git/",
".hg/",
".julia/",
".mypy_cache/",
".nox/",
".pytest_cache/",
".ruff_cache/",
".svn/",
".tmp/",
".tox/",
".venv/",
".vscode/",
"_book/",
"_build/",
"_freeze/",
"_site/",
"build/",
"dist/",
"node_modules/",
"renv/",
"target/",
"tests/testthat/_snaps",
"**/LICENSE.md",
];
pub const DEFAULT_INCLUDE_PATTERNS: &[&str] =
&["*.md", "*.qmd", "*.Rmd", "*.markdown", "*.mdown", "*.mkd"];
const MARKDOWN_FAMILY_EXTENSIONS: &[&str] = &["md", "markdown", "mdown", "mkd"];
fn resolve_formatter_name(
name: &str,
formatter_definitions: &HashMap<String, FormatterDefinition>,
) -> Result<FormatterConfig, String> {
if let Some(definition) = formatter_definitions.get(name) {
if definition.preset.is_some() {
return Err(format!(
"Formatter '{}': 'preset' field not allowed in named definitions. Use [formatters] mapping instead (e.g., `lang = \"{}\"`).",
name, name
));
}
let preset = get_formatter_preset(name);
match (preset, &definition.cmd) {
(Some(mut base_config), _) => {
if let Some(cmd) = &definition.cmd {
base_config.cmd = cmd.clone();
}
if let Some(args) = &definition.args {
base_config.args = args.clone();
}
if let Some(stdin) = definition.stdin {
base_config.stdin = stdin;
}
apply_arg_modifiers(&mut base_config.args, definition);
Ok(base_config)
}
(None, Some(cmd)) => {
let mut args = definition.args.clone().unwrap_or_default();
apply_arg_modifiers(&mut args, definition);
Ok(FormatterConfig {
cmd: cmd.clone(),
args,
enabled: true,
stdin: definition.stdin.unwrap_or(true),
})
}
(None, None) => Err(format!(
"Formatter '{}': must specify 'cmd' field (not a known preset)",
name
)),
}
} else {
get_formatter_preset(name).ok_or_else(|| {
format!(
"Unknown formatter '{}': not a named definition or built-in preset. \
Define it in [formatters.{}] section or use a known preset.",
name, name
)
})
}
}
fn apply_arg_modifiers(args: &mut Vec<String>, definition: &FormatterDefinition) {
if let Some(prepend) = &definition.prepend_args {
let mut new_args = prepend.clone();
new_args.append(args);
*args = new_args;
}
if let Some(append) = &definition.append_args {
args.extend_from_slice(append);
}
}
fn resolve_language_formatters(
lang: &str,
value: &FormatterValue,
formatter_definitions: &HashMap<String, FormatterDefinition>,
) -> Result<Vec<FormatterConfig>, String> {
let formatter_names = match value {
FormatterValue::Single(name) => vec![name.as_str()],
FormatterValue::Multiple(names) => names.iter().map(|s| s.as_str()).collect(),
};
formatter_names
.into_iter()
.map(|name| {
if !formatter_definitions.contains_key(name) {
validate_formatter_language_for_preset(lang, name)?;
}
resolve_formatter_name(name, formatter_definitions)
.map_err(|e| format!("Language '{}': {}", lang, e))
})
.collect()
}
impl RawConfig {
fn finalize(self) -> Config {
let parser_from_section = self.parser.unwrap_or_default();
let parser_pandoc_compat = parser_from_section.pandoc_compat;
let resolved_pandoc_compat = if let Some(pandoc_compat) = self.pandoc_compat {
if parser_pandoc_compat != PandocCompat::default()
&& parser_pandoc_compat != pandoc_compat
{
eprintln!(
"Warning: Both top-level 'pandoc-compat' and [parser].pandoc-compat are set. Using top-level 'pandoc-compat'."
);
}
pandoc_compat
} else {
if parser_pandoc_compat != PandocCompat::default() {
eprintln!(
"Warning: [parser].pandoc-compat is deprecated. Please use top-level 'pandoc-compat'."
);
}
parser_pandoc_compat
};
let has_deprecated_fields = self.wrap.is_some()
|| self.math_indent != 0
|| self.math_delimiter_style != MathDelimiterStyle::default()
|| self.blank_lines != default_blank_lines()
|| self.tab_stops != TabStopMode::Normalize
|| self.tab_width != default_tab_width();
if has_deprecated_fields && self.format_section.is_none() && self.style.is_none() {
eprintln!(
"Warning: top-level style fields (wrap, math-indent, etc.) \
are deprecated. Please move them under [format] section. \
See documentation for the new format."
);
}
let style = if let Some(format_config) = self.format_section {
if self.style.is_some() {
eprintln!(
"Warning: Both [format] and deprecated [style] sections found. \
Using [format] section."
);
}
if has_deprecated_fields {
eprintln!(
"Warning: Both [format] section and top-level style fields found. \
Using [format] section and ignoring top-level fields."
);
}
format_config
} else if let Some(style_config) = self.style {
eprintln!("Warning: [style] section is deprecated. Please use [format] instead.");
if has_deprecated_fields {
eprintln!(
"Warning: Both deprecated [style] section and top-level style fields found. \
Using [style] section and ignoring top-level fields."
);
}
style_config
} else {
StyleConfig {
wrap: self.wrap.or(Some(WrapMode::Reflow)),
blank_lines: self.blank_lines,
math_delimiter_style: self.math_delimiter_style,
math_indent: self.math_indent,
tab_stops: self.tab_stops,
tab_width: self.tab_width,
built_in_greedy_wrap: true,
}
};
Config {
extensions: resolve_extensions_for_flavor(self.extensions.as_ref(), self.flavor),
line_ending: self.line_ending.or(Some(LineEnding::Auto)),
flavor: self.flavor,
line_width: self.line_width,
wrap: style.wrap,
blank_lines: style.blank_lines,
math_delimiter_style: style.math_delimiter_style,
math_indent: style.math_indent,
tab_stops: style.tab_stops,
tab_width: style.tab_width,
formatters: resolve_formatters(self.formatters),
linters: self.linters,
lint: self.lint.unwrap_or_default().normalize(),
cache_dir: self.cache_dir,
external_max_parallel: self
.external_max_parallel
.unwrap_or_else(default_external_max_parallel),
parser: ParserConfig {
pandoc_compat: resolved_pandoc_compat,
},
built_in_greedy_wrap: style.built_in_greedy_wrap,
exclude: self.exclude,
extend_exclude: self.extend_exclude,
include: self.include,
extend_include: self.extend_include,
flavor_overrides: self.flavor_overrides,
}
}
}
fn parse_flavor_key(s: &str) -> Option<Flavor> {
match s.replace('_', "-").to_lowercase().as_str() {
"pandoc" => Some(Flavor::Pandoc),
"quarto" => Some(Flavor::Quarto),
"rmarkdown" | "r-markdown" => Some(Flavor::RMarkdown),
"gfm" => Some(Flavor::Gfm),
"common-mark" | "commonmark" => Some(Flavor::CommonMark),
"multimarkdown" | "multi-markdown" => Some(Flavor::MultiMarkdown),
_ => None,
}
}
fn resolve_extensions_for_flavor(
extensions_value: Option<&toml::Value>,
flavor: Flavor,
) -> Extensions {
let Some(value) = extensions_value else {
return Extensions::for_flavor(flavor);
};
let Some(table) = value.as_table() else {
eprintln!("Warning: [extensions] must be a table; using flavor defaults.");
return Extensions::for_flavor(flavor);
};
let mut global_overrides = HashMap::new();
let mut flavor_overrides = HashMap::new();
for (key, val) in table {
if let Some(enabled) = val.as_bool() {
global_overrides.insert(key.clone(), enabled);
continue;
}
let Some(flavor_table) = val.as_table() else {
eprintln!(
"Warning: [extensions] entry '{}' must be a boolean or table; ignoring.",
key
);
continue;
};
let Some(target_flavor) = parse_flavor_key(key) else {
eprintln!(
"Warning: [extensions.{}] is not a known flavor table; ignoring.",
key
);
continue;
};
if target_flavor != flavor {
continue;
}
for (sub_key, sub_val) in flavor_table {
let Some(enabled) = sub_val.as_bool() else {
eprintln!(
"Warning: [extensions.{}] entry '{}' must be true or false; ignoring.",
key, sub_key
);
continue;
};
flavor_overrides.insert(sub_key.clone(), enabled);
}
}
let base = Extensions::merge_with_flavor(global_overrides, flavor);
Extensions::merge_overrides(base, flavor_overrides)
}
fn resolve_formatters(
raw_formatters: Option<toml::Value>,
) -> HashMap<String, Vec<FormatterConfig>> {
let Some(value) = raw_formatters else {
return HashMap::new();
};
let toml::Value::Table(table) = value else {
eprintln!("Warning: Invalid formatters configuration - expected table");
return HashMap::new();
};
let has_string_or_array = table
.values()
.any(|v| matches!(v, toml::Value::String(_) | toml::Value::Array(_)));
if has_string_or_array {
resolve_new_format_formatters(table)
} else {
resolve_old_format_formatters(table)
}
}
fn resolve_new_format_formatters(
table: toml::map::Map<String, toml::Value>,
) -> HashMap<String, Vec<FormatterConfig>> {
let mut mappings = HashMap::new();
let mut definitions = HashMap::new();
for (key, value) in table {
match &value {
toml::Value::String(_) | toml::Value::Array(_) => {
let formatter_value: Result<FormatterValue, _> = value.try_into();
match formatter_value {
Ok(fv) => {
mappings.insert(key, fv);
}
Err(e) => {
eprintln!("Error parsing formatter value for '{}': {}", key, e);
}
}
}
toml::Value::Table(_) => {
let definition: Result<FormatterDefinition, _> = value.try_into();
match definition {
Ok(def) => {
definitions.insert(key, def);
}
Err(e) => {
eprintln!("Error parsing formatter definition '{}': {}", key, e);
}
}
}
_ => {
eprintln!(
"Warning: Invalid formatter entry '{}' - must be string, array, or table",
key
);
}
}
}
let mut resolved = HashMap::new();
for (lang, value) in mappings {
match resolve_language_formatters(&lang, &value, &definitions) {
Ok(configs) if !configs.is_empty() => {
resolved.insert(lang, configs);
}
Ok(_) => {} Err(e) => {
eprintln!("Error resolving formatters for language '{}': {}", lang, e);
eprintln!("Skipping formatter for '{}'", lang);
}
}
}
resolved
}
fn resolve_old_format_formatters(
table: toml::map::Map<String, toml::Value>,
) -> HashMap<String, Vec<FormatterConfig>> {
eprintln!(
"Warning: Old formatter configuration format detected. \
Please migrate to the new format with [formatters] section. \
See documentation for the new format."
);
let mut resolved = HashMap::new();
for (lang, value) in table {
let definition: Result<FormatterDefinition, _> = value.try_into();
match definition {
Ok(def) => {
if def.enabled == Some(false) {
continue;
}
match resolve_old_format_definition(&lang, &def) {
Ok(config) => {
resolved.insert(lang, vec![config]);
}
Err(e) => {
eprintln!("Error in old formatter config for '{}': {}", lang, e);
eprintln!("Skipping formatter for '{}'", lang);
}
}
}
Err(e) => {
eprintln!("Error parsing old formatter config for '{}': {}", lang, e);
}
}
}
resolved
}
fn resolve_old_format_definition(
_lang: &str,
definition: &FormatterDefinition,
) -> Result<FormatterConfig, String> {
if definition.preset.is_some() && definition.cmd.is_some() {
return Err("'preset' and 'cmd' are mutually exclusive".to_string());
}
if let Some(preset_name) = &definition.preset {
let preset = get_formatter_preset(preset_name).ok_or_else(|| {
let available = formatter_preset_names().join(", ");
format!(
"Unknown formatter preset '{}'. Available presets: {}",
preset_name, available
)
})?;
let mut args = definition.args.clone().unwrap_or(preset.args);
apply_arg_modifiers(&mut args, definition);
Ok(FormatterConfig {
cmd: preset.cmd,
args,
enabled: true, stdin: preset.stdin,
})
} else if let Some(cmd) = &definition.cmd {
let mut args = definition.args.clone().unwrap_or_default();
apply_arg_modifiers(&mut args, definition);
Ok(FormatterConfig {
cmd: cmd.clone(),
args,
enabled: true,
stdin: definition.stdin.unwrap_or(true),
})
} else {
Err("must specify either 'preset' or 'cmd'".to_string())
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub flavor: Flavor,
pub extensions: Extensions,
pub line_ending: Option<LineEnding>,
pub line_width: usize,
pub math_indent: usize,
pub math_delimiter_style: MathDelimiterStyle,
pub tab_stops: TabStopMode,
pub tab_width: usize,
pub wrap: Option<WrapMode>,
pub blank_lines: BlankLines,
pub formatters: HashMap<String, Vec<FormatterConfig>>,
pub linters: HashMap<String, String>,
pub external_max_parallel: usize,
pub parser: ParserConfig,
pub lint: LintConfig,
pub cache_dir: Option<String>,
pub built_in_greedy_wrap: bool,
pub exclude: Option<Vec<String>>,
pub extend_exclude: Vec<String>,
pub include: Option<Vec<String>>,
pub extend_include: Vec<String>,
pub flavor_overrides: HashMap<String, Flavor>,
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
RawConfig::deserialize(deserializer).map(|raw| raw.finalize())
}
}
impl Default for Config {
fn default() -> Self {
let flavor = Flavor::default();
Self {
flavor,
extensions: Extensions::for_flavor(flavor),
line_ending: Some(LineEnding::Auto),
line_width: 80,
math_indent: 0,
math_delimiter_style: MathDelimiterStyle::default(),
tab_stops: TabStopMode::Normalize,
tab_width: 4,
wrap: Some(WrapMode::Reflow),
blank_lines: BlankLines::Collapse,
formatters: HashMap::new(), linters: HashMap::new(), external_max_parallel: default_external_max_parallel(),
parser: ParserConfig::default(),
lint: LintConfig::default(),
cache_dir: None,
built_in_greedy_wrap: true,
exclude: None,
extend_exclude: Vec::new(),
include: None,
extend_include: Vec::new(),
flavor_overrides: HashMap::new(),
}
}
}
#[derive(Default, Clone)]
pub struct ConfigBuilder {
config: Config,
}
impl ConfigBuilder {
pub fn math_indent(mut self, indent: usize) -> Self {
self.config.math_indent = indent;
self
}
pub fn tab_stops(mut self, mode: TabStopMode) -> Self {
self.config.tab_stops = mode;
self
}
pub fn tab_width(mut self, width: usize) -> Self {
self.config.tab_width = width;
self
}
pub fn line_width(mut self, width: usize) -> Self {
self.config.line_width = width;
self
}
pub fn line_ending(mut self, ending: LineEnding) -> Self {
self.config.line_ending = Some(ending);
self
}
pub fn blank_lines(mut self, mode: BlankLines) -> Self {
self.config.blank_lines = mode;
self
}
pub fn build(self) -> Config {
self.config
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum WrapMode {
Preserve,
Reflow,
Sentence,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum LineEnding {
Auto,
Lf,
Crlf,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum BlankLines {
Preserve,
Collapse,
}
const CANDIDATE_NAMES: &[&str] = &[".panache.toml", "panache.toml"];
fn check_deprecated_extension_names(s: &str, path: &Path) {
let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
return; };
let Some(extensions_table) = toml_value
.as_table()
.and_then(|t| t.get("extensions"))
.and_then(|v| v.as_table())
else {
return; };
let deprecated_names: Vec<&str> = extensions_table
.keys()
.filter(|k| k.contains('_'))
.map(|k| k.as_str())
.collect();
if !deprecated_names.is_empty() {
eprintln!(
"Warning: Deprecated snake_case extension names found in {}:",
path.display()
);
eprintln!(" The following extensions use deprecated snake_case naming:");
for name in &deprecated_names {
let kebab = name.replace('_', "-");
eprintln!(" {} -> {} (use kebab-case)", name, kebab);
}
eprintln!(" Snake_case extension names are deprecated and will be removed in v1.0.0.");
eprintln!(
" Please update your config to use kebab-case (e.g., quarto-crossrefs instead of quarto_crossrefs)."
);
}
}
fn check_deprecated_formatter_names(s: &str, path: &Path) {
let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
return;
};
let Some(formatters_table) = toml_value
.as_table()
.and_then(|t| t.get("formatters"))
.and_then(|v| v.as_table())
else {
return; };
let mut found_deprecated = false;
for (formatter_name, formatter_value) in formatters_table {
if let Some(formatter_def) = formatter_value.as_table() {
let deprecated_fields: Vec<&str> = formatter_def
.keys()
.filter(|k| matches!(k.as_str(), "prepend_args" | "append_args"))
.map(|k| k.as_str())
.collect();
if !deprecated_fields.is_empty() {
if !found_deprecated {
eprintln!(
"Warning: Deprecated snake_case formatter field names found in {}:",
path.display()
);
found_deprecated = true;
}
eprintln!(" In [formatters.{}]:", formatter_name);
for field in deprecated_fields {
let kebab = field.replace('_', "-");
eprintln!(" {} -> {}", field, kebab);
}
}
}
}
if found_deprecated {
eprintln!(
" Snake_case formatter field names are deprecated and will be removed in v1.0.0."
);
eprintln!(
" Please update your config to use kebab-case (e.g., prepend-args instead of prepend_args)."
);
}
}
fn check_deprecated_code_block_style_options(s: &str, path: &Path) {
let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
return;
};
let Some(root) = toml_value.as_table() else {
return;
};
let top_level = root.contains_key("code-blocks");
let format_nested = root
.get("format")
.and_then(|v| v.as_table())
.is_some_and(|format| format.contains_key("code-blocks"));
let style_nested = root
.get("style")
.and_then(|v| v.as_table())
.is_some_and(|style| style.contains_key("code-blocks"));
if top_level || format_nested || style_nested {
eprintln!(
"Warning: Deprecated code block style options found in {}:",
path.display()
);
if format_nested {
eprintln!(" - [format.code-blocks]");
}
if top_level {
eprintln!(" - [code-blocks]");
}
if style_nested {
eprintln!(" - [style.code-blocks]");
}
eprintln!(" These options are now no-ops and will be removed in a future release.");
}
}
fn parse_config_str(s: &str, path: &Path) -> io::Result<Config> {
check_deprecated_extension_names(s, path);
check_deprecated_formatter_names(s, path);
check_deprecated_code_block_style_options(s, path);
let config: Config = toml::from_str(s).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid config {}: {e}", path.display()),
)
})?;
Ok(config)
}
fn read_config(path: &Path) -> io::Result<Config> {
log::debug!("Reading config from: {}", path.display());
let s = fs::read_to_string(path)?;
let config = parse_config_str(&s, path)?;
log::debug!("Loaded config from: {}", path.display());
Ok(config)
}
fn find_in_tree(start_dir: &Path) -> Option<PathBuf> {
for dir in start_dir.ancestors() {
for name in CANDIDATE_NAMES {
let p = dir.join(name);
if p.is_file() {
return Some(p);
}
}
}
None
}
fn xdg_config_path() -> Option<PathBuf> {
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
let p = Path::new(&xdg).join("panache").join("config.toml");
if p.is_file() {
return Some(p);
}
}
if let Ok(home) = env::var("HOME") {
let p = Path::new(&home)
.join(".config")
.join("panache")
.join("config.toml");
if p.is_file() {
return Some(p);
}
}
None
}
pub fn load(
explicit: Option<&Path>,
start_dir: &Path,
input_file: Option<&Path>,
) -> io::Result<(Config, Option<PathBuf>)> {
let (mut cfg, cfg_path) = if let Some(path) = explicit {
let cfg = read_config(path)?;
(cfg, Some(path.to_path_buf()))
} else if let Some(p) = find_in_tree(start_dir)
&& let Ok(cfg) = read_config(&p)
{
(cfg, Some(p))
} else if let Some(p) = xdg_config_path()
&& let Ok(cfg) = read_config(&p)
{
(cfg, Some(p))
} else {
log::debug!("No config file found, using defaults");
(Config::default(), None)
};
if let Some(flavor) = detect_flavor(input_file, cfg_path.as_deref(), &cfg) {
cfg.flavor = flavor;
cfg.extensions = if let Some(path) = cfg_path.as_deref() {
fs::read_to_string(path)
.ok()
.and_then(|s| toml::from_str::<toml::Value>(&s).ok())
.map(|root| resolve_extensions_for_flavor(root.get("extensions"), flavor))
.unwrap_or_else(|| Extensions::for_flavor(flavor))
} else {
Extensions::for_flavor(flavor)
};
}
Ok((cfg, cfg_path))
}
fn detect_flavor(
input_file: Option<&Path>,
cfg_path: Option<&Path>,
cfg: &Config,
) -> Option<Flavor> {
let input_path = input_file?;
let ext = input_path.extension().and_then(|e| e.to_str())?;
let ext_lower = ext.to_lowercase();
match ext_lower.as_str() {
"qmd" => {
log::debug!("Using Quarto flavor for .qmd file");
Some(Flavor::Quarto)
}
"rmd" => {
log::debug!("Using RMarkdown flavor for .Rmd file");
Some(Flavor::RMarkdown)
}
_ if MARKDOWN_FAMILY_EXTENSIONS.contains(&ext_lower.as_str()) => {
let base_dir = cfg_path.and_then(Path::parent);
let override_flavor =
detect_flavor_override(input_path, base_dir, &cfg.flavor_overrides);
let final_flavor = override_flavor.unwrap_or(cfg.flavor);
if let Some(flavor) = override_flavor {
log::debug!(
"Using {:?} flavor for {} (matched flavor-overrides)",
flavor,
input_path.display()
);
} else {
log::debug!(
"Using {:?} flavor for {} (from config)",
final_flavor,
input_path.display()
);
}
Some(final_flavor)
}
_ => None,
}
}
fn detect_flavor_override(
input_path: &Path,
base_dir: Option<&Path>,
overrides: &HashMap<String, Flavor>,
) -> Option<Flavor> {
if overrides.is_empty() {
return None;
}
let full_path = normalize_path_for_matching(input_path);
let rel_path = base_dir
.and_then(|base| input_path.strip_prefix(base).ok())
.map(normalize_path_for_matching);
let file_name = input_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string());
let mut best: Option<((usize, usize, usize), Flavor)> = None;
for (pattern, flavor) in overrides {
let matched = glob_matches_path(pattern, &full_path)
|| rel_path
.as_deref()
.is_some_and(|relative| glob_matches_path(pattern, relative))
|| file_name
.as_deref()
.is_some_and(|name| glob_matches_path(pattern, name));
if !matched {
continue;
}
let score = pattern_specificity(pattern);
if best.is_none_or(|(best_score, _)| score > best_score) {
best = Some((score, *flavor));
}
}
best.map(|(_, flavor)| flavor)
}
fn normalize_path_for_matching(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn pattern_specificity(pattern: &str) -> (usize, usize, usize) {
let literal_chars = pattern.chars().filter(|c| *c != '*' && *c != '?').count();
let segment_count = pattern
.split('/')
.filter(|segment| !segment.is_empty())
.count();
let wildcard_count = pattern.chars().filter(|c| *c == '*' || *c == '?').count();
(literal_chars, segment_count, usize::MAX - wildcard_count)
}
fn glob_matches_path(pattern: &str, path: &str) -> bool {
let normalized_pattern = pattern.replace('\\', "/");
GlobBuilder::new(&normalized_pattern)
.literal_separator(true)
.build()
.map(|glob| glob.compile_matcher().is_match(path))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_fields_uses_defaults() {
let toml_str = r#"
wrap = "reflow"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.line_width, 80);
assert!(cfg.formatters.is_empty());
assert!(cfg.cache_dir.is_none());
}
#[test]
fn formatter_config_basic() {
let toml_str = r#"
[formatters.python]
cmd = "black"
args = ["-"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let python_fmt = &cfg.formatters.get("python").unwrap()[0];
assert_eq!(python_fmt.cmd, "black");
assert_eq!(python_fmt.args, vec!["-"]);
assert!(python_fmt.enabled);
}
#[test]
fn formatter_config_multiple_languages() {
let toml_str = r#"
[formatters.r]
cmd = "air"
args = ["--preset=tidyverse"]
[formatters.python]
cmd = "black"
args = ["-", "--line-length=88"]
[formatters.rust]
cmd = "rustfmt"
enabled = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.formatters.len(), 2);
let r_fmt = &cfg.formatters.get("r").unwrap()[0];
assert_eq!(r_fmt.cmd, "air");
assert_eq!(r_fmt.args, vec!["--preset=tidyverse"]);
assert!(r_fmt.enabled);
let py_fmt = &cfg.formatters.get("python").unwrap()[0];
assert_eq!(py_fmt.cmd, "black");
assert_eq!(py_fmt.args.len(), 2);
assert!(!cfg.formatters.contains_key("rust"));
}
#[test]
fn cache_dir_parsing() {
let toml_str = r#"
cache-dir = ".panache/local-cache"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.cache_dir.as_deref(), Some(".panache/local-cache"));
}
#[test]
fn formatter_config_no_args() {
let toml_str = r#"
[formatters.rustfmt]
cmd = "rustfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("rustfmt").unwrap()[0];
assert_eq!(fmt.cmd, "rustfmt");
assert!(fmt.args.is_empty());
assert!(fmt.enabled);
}
#[test]
fn formatter_empty_cmd_is_valid() {
let toml_str = r#"
[formatters.test]
cmd = ""
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("test").unwrap()[0];
assert_eq!(fmt.cmd, "");
}
#[test]
fn preset_resolution_air() {
let toml_str = r#"
[formatters.r]
preset = "air"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmt = &cfg.formatters.get("r").unwrap()[0];
assert_eq!(r_fmt.cmd, "air");
assert_eq!(r_fmt.args, vec!["format", "{}"]);
assert!(!r_fmt.stdin);
assert!(r_fmt.enabled);
}
#[test]
fn preset_resolution_ruff() {
let toml_str = r#"
[formatters.python]
preset = "ruff"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let py_fmt = &cfg.formatters.get("python").unwrap()[0];
assert_eq!(py_fmt.cmd, "ruff");
assert_eq!(
py_fmt.args,
vec!["format", "--stdin-filename", "stdin.py", "-"]
);
assert!(py_fmt.stdin);
assert!(py_fmt.enabled);
}
#[test]
fn preset_resolution_black() {
let toml_str = r#"
[formatters.python]
preset = "black"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let py_fmt = &cfg.formatters.get("python").unwrap()[0];
assert_eq!(py_fmt.cmd, "black");
assert_eq!(py_fmt.args, vec!["-"]);
assert!(py_fmt.stdin);
}
#[test]
fn preset_resolution_sqlfmt() {
let toml_str = r#"
[formatters.sql]
preset = "sqlfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("sql").unwrap()[0];
assert_eq!(fmt.cmd, "sqlfmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_alejandra() {
let toml_str = r#"
[formatters.nix]
preset = "alejandra"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("nix").unwrap()[0];
assert_eq!(fmt.cmd, "alejandra");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_terraform_fmt() {
let toml_str = r#"
[formatters.hcl]
preset = "terraform-fmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("hcl").unwrap()[0];
assert_eq!(fmt.cmd, "terraform");
assert_eq!(fmt.args, vec!["fmt", "-no-color", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_yamlfix() {
let toml_str = r#"
[formatters.yaml]
preset = "yamlfix"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("yaml").unwrap()[0];
assert_eq!(fmt.cmd, "yamlfix");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_gofmt() {
let toml_str = r#"
[formatters.go]
preset = "gofmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("go").unwrap()[0];
assert_eq!(fmt.cmd, "gofmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_gofumpt() {
let toml_str = r#"
[formatters.go]
preset = "gofumpt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("go").unwrap()[0];
assert_eq!(fmt.cmd, "gofumpt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_nixfmt() {
let toml_str = r#"
[formatters.nix]
preset = "nixfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("nix").unwrap()[0];
assert_eq!(fmt.cmd, "nixfmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_gleam() {
let toml_str = r#"
[formatters.gleam]
preset = "gleam"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("gleam").unwrap()[0];
assert_eq!(fmt.cmd, "gleam");
assert_eq!(fmt.args, vec!["format", "--stdin"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_yq() {
let toml_str = r#"
[formatters.yaml]
preset = "yq"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("yaml").unwrap()[0];
assert_eq!(fmt.cmd, "yq");
assert_eq!(fmt.args, vec!["-P", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_asmfmt() {
let toml_str = r#"
[formatters.asm]
preset = "asmfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("asm").unwrap()[0];
assert_eq!(fmt.cmd, "asmfmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_astyle() {
let toml_str = r#"
[formatters.cpp]
preset = "astyle"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("cpp").unwrap()[0];
assert_eq!(fmt.cmd, "astyle");
assert_eq!(fmt.args, vec!["--quiet"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_autocorrect() {
let toml_str = r#"
[formatters.text]
preset = "autocorrect"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("text").unwrap()[0];
assert_eq!(fmt.cmd, "autocorrect");
assert_eq!(fmt.args, vec!["--stdin"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_cmake_format() {
let toml_str = r#"
[formatters.cmake]
preset = "cmake-format"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("cmake").unwrap()[0];
assert_eq!(fmt.cmd, "cmake-format");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_cue_fmt() {
let toml_str = r#"
[formatters.cue]
preset = "cue-fmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("cue").unwrap()[0];
assert_eq!(fmt.cmd, "cue");
assert_eq!(fmt.args, vec!["fmt", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_jsonnetfmt() {
let toml_str = r#"
[formatters.jsonnet]
preset = "jsonnetfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("jsonnet").unwrap()[0];
assert_eq!(fmt.cmd, "jsonnetfmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_dfmt() {
let toml_str = r#"
[formatters.d]
preset = "dfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("d").unwrap()[0];
assert_eq!(fmt.cmd, "dfmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_efmt() {
let toml_str = r#"
[formatters.erl]
preset = "efmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("erl").unwrap()[0];
assert_eq!(fmt.cmd, "efmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_nginxfmt() {
let toml_str = r#"
[formatters.nginx]
preset = "nginxfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("nginx").unwrap()[0];
assert_eq!(fmt.cmd, "nginxfmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_tclfmt() {
let toml_str = r#"
[formatters.tcl]
preset = "tclfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("tcl").unwrap()[0];
assert_eq!(fmt.cmd, "tclfmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_tex_fmt() {
let toml_str = r#"
[formatters.tex]
preset = "tex-fmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("tex").unwrap()[0];
assert_eq!(fmt.cmd, "tex-fmt");
assert_eq!(fmt.args, vec!["-s"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_typstyle() {
let toml_str = r#"
[formatters.typst]
preset = "typstyle"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("typst").unwrap()[0];
assert_eq!(fmt.cmd, "typstyle");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_gdformat() {
let toml_str = r#"
[formatters.gdscript]
preset = "gdformat"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("gdscript").unwrap()[0];
assert_eq!(fmt.cmd, "gdformat");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_hurlfmt() {
let toml_str = r#"
[formatters.hurl]
preset = "hurlfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("hurl").unwrap()[0];
assert_eq!(fmt.cmd, "hurlfmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_ktfmt() {
let toml_str = r#"
[formatters.kotlin]
preset = "ktfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("kotlin").unwrap()[0];
assert_eq!(fmt.cmd, "ktfmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_leptosfmt() {
let toml_str = r#"
[formatters.rust]
preset = "leptosfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("rust").unwrap()[0];
assert_eq!(fmt.cmd, "leptosfmt");
assert_eq!(fmt.args, vec!["--stdin"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_pycln() {
let toml_str = r#"
[formatters.python]
preset = "pycln"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("python").unwrap()[0];
assert_eq!(fmt.cmd, "pycln");
assert_eq!(fmt.args, vec!["--silence", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_pyproject_fmt() {
let toml_str = r#"
[formatters.toml]
preset = "pyproject-fmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("toml").unwrap()[0];
assert_eq!(fmt.cmd, "pyproject-fmt");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_google_java_format() {
let toml_str = r#"
[formatters.java]
preset = "google-java-format"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("java").unwrap()[0];
assert_eq!(fmt.cmd, "google-java-format");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_racketfmt() {
let toml_str = r#"
[formatters.racket]
preset = "racketfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("racket").unwrap()[0];
assert_eq!(fmt.cmd, "raco");
assert_eq!(fmt.args, vec!["fmt"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_rubyfmt() {
let toml_str = r#"
[formatters.ruby]
preset = "rubyfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("ruby").unwrap()[0];
assert_eq!(fmt.cmd, "rubyfmt");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_rufo() {
let toml_str = r#"
[formatters.ruby]
preset = "rufo"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("ruby").unwrap()[0];
assert_eq!(fmt.cmd, "rufo");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_bean_format() {
let toml_str = r#"
[formatters.beancount]
preset = "bean-format"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("beancount").unwrap()[0];
assert_eq!(fmt.cmd, "bean-format");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_beautysh() {
let toml_str = r#"
[formatters.bash]
preset = "beautysh"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("bash").unwrap()[0];
assert_eq!(fmt.cmd, "beautysh");
assert_eq!(fmt.args, vec!["-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_cljfmt() {
let toml_str = r#"
[formatters.clojure]
preset = "cljfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("clojure").unwrap()[0];
assert_eq!(fmt.cmd, "cljfmt");
assert_eq!(fmt.args, vec!["fix", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_fish_indent() {
let toml_str = r#"
[formatters.fish]
preset = "fish_indent"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("fish").unwrap()[0];
assert_eq!(fmt.cmd, "fish_indent");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_fixjson() {
let toml_str = r#"
[formatters.json]
preset = "fixjson"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("json").unwrap()[0];
assert_eq!(fmt.cmd, "fixjson");
assert!(fmt.args.is_empty());
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_bibtex_tidy() {
let toml_str = r#"
[formatters.bibtex]
preset = "bibtex-tidy"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("bibtex").unwrap()[0];
assert_eq!(fmt.cmd, "bibtex-tidy");
assert_eq!(fmt.args, vec!["--quiet"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_bpfmt() {
let toml_str = r#"
[formatters.bp]
preset = "bpfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("bp").unwrap()[0];
assert_eq!(fmt.cmd, "bpfmt");
assert_eq!(fmt.args, vec!["-w", "{}"]);
assert!(!fmt.stdin);
}
#[test]
fn preset_resolution_bsfmt() {
let toml_str = r#"
[formatters.brs]
preset = "bsfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("brs").unwrap()[0];
assert_eq!(fmt.cmd, "bsfmt");
assert_eq!(fmt.args, vec!["{}", "--write"]);
assert!(!fmt.stdin);
}
#[test]
fn preset_resolution_buf() {
let toml_str = r#"
[formatters.proto]
preset = "buf"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("proto").unwrap()[0];
assert_eq!(fmt.cmd, "buf");
assert_eq!(fmt.args, vec!["format", "-w", "{}"]);
assert!(!fmt.stdin);
}
#[test]
fn preset_resolution_buildifier() {
let toml_str = r#"
[formatters.bazel]
preset = "buildifier"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("bazel").unwrap()[0];
assert_eq!(fmt.cmd, "buildifier");
assert_eq!(fmt.args, vec!["-path", "{}", "-"]);
assert!(fmt.stdin);
}
#[test]
fn preset_resolution_cabal_fmt() {
let toml_str = r#"
[formatters.cabal]
preset = "cabal-fmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("cabal").unwrap()[0];
assert_eq!(fmt.cmd, "cabal-fmt");
assert_eq!(fmt.args, vec!["--inplace", "{}"]);
assert!(!fmt.stdin);
}
#[test]
fn preset_resolution_prettier_typescript() {
let toml_str = r#"
[formatters.typescript]
preset = "prettier"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("typescript").unwrap()[0];
assert_eq!(fmt.cmd, "prettier");
assert_eq!(fmt.args, vec!["--stdin-filepath", "{}"]);
assert!(fmt.stdin);
}
#[test]
fn preset_and_cmd_mutually_exclusive() {
let toml_str = r#"
[formatters.r]
preset = "air"
cmd = "styler"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("r"));
}
#[test]
fn unknown_preset_fails() {
let toml_str = r#"
[formatters.r]
preset = "nonexistent"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("r"));
}
#[test]
fn preset_language_mismatch_is_rejected() {
let toml_str = r#"
[formatters]
python = "gofmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("python"));
}
#[test]
fn preset_language_alias_is_accepted() {
let toml_str = r#"
[formatters]
yml = "yamlfmt"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmts = cfg.formatters.get("yml").unwrap();
assert_eq!(fmts.len(), 1);
assert_eq!(fmts[0].cmd, "yamlfmt");
}
#[test]
fn named_definition_skips_builtin_language_guard() {
let toml_str = r#"
[formatters]
javascript = "prettier"
[formatters.prettier]
cmd = "prettier"
args = ["--print-width=100"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmts = cfg.formatters.get("javascript").unwrap();
assert_eq!(fmts.len(), 1);
assert_eq!(fmts[0].cmd, "prettier");
assert_eq!(fmts[0].args, vec!["--print-width=100"]);
}
#[test]
fn preset_metadata_lookup_contains_url() {
let meta = formatter_preset_metadata("gofmt").unwrap();
assert_eq!(meta.name, "gofmt");
assert_eq!(meta.cmd, "gofmt");
assert!(meta.url.contains("pkg.go.dev"));
}
#[test]
fn preset_metadata_language_lookup_works() {
let names: Vec<&str> = formatter_presets_for_language("yaml")
.iter()
.map(|meta| meta.name)
.collect();
assert!(names.contains(&"yamlfmt"));
assert!(names.contains(&"yamlfix"));
assert!(names.contains(&"yq"));
}
#[test]
fn preset_metadata_language_lookup_includes_prettier_for_typescript() {
let names: Vec<&str> = formatter_presets_for_language("typescript")
.iter()
.map(|meta| meta.name)
.collect();
assert!(names.contains(&"prettier"));
}
#[test]
fn builtin_defaults_when_no_config() {
let cfg = Config::default();
assert!(cfg.formatters.is_empty());
assert!(cfg.lint.is_rule_enabled("heading-hierarchy"));
assert!(cfg.lint.is_rule_enabled("undefined-references"));
}
#[test]
fn lint_config_allows_rule_toggles() {
let toml_str = r#"
[lint.rules]
heading-hierarchy = false
undefined-references = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.lint.is_rule_enabled("heading-hierarchy"));
assert!(!cfg.lint.is_rule_enabled("undefined-references"));
assert!(cfg.lint.is_rule_enabled("duplicate-reference-labels"));
}
#[test]
fn lint_config_normalizes_snake_case_rule_names() {
let toml_str = r#"
[lint.rules]
undefined_references = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.lint.is_rule_enabled("undefined-references"));
}
#[test]
fn lint_config_legacy_top_level_rules_still_supported() {
let toml_str = r#"
[lint]
undefined-references = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.lint.is_rule_enabled("undefined-references"));
}
#[test]
fn path_selector_fields_parse() {
let toml_str = r#"
exclude = ["tests/", "build/"]
extend-exclude = ["snapshots/"]
include = ["*.qmd"]
extend-include = ["*.md"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(
cfg.exclude,
Some(vec!["tests/".to_string(), "build/".to_string()])
);
assert_eq!(cfg.extend_exclude, vec!["snapshots/".to_string()]);
assert_eq!(cfg.include, Some(vec!["*.qmd".to_string()]));
assert_eq!(cfg.extend_include, vec!["*.md".to_string()]);
}
#[test]
fn path_selector_fields_default_to_unset_or_empty() {
let cfg = toml::from_str::<Config>("line-width = 100").unwrap();
assert!(cfg.exclude.is_none());
assert!(cfg.extend_exclude.is_empty());
assert!(cfg.include.is_none());
assert!(cfg.extend_include.is_empty());
}
#[test]
fn default_exclude_patterns_include_license_md() {
assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"**/LICENSE.md"));
}
#[test]
fn flavor_overrides_parse() {
let toml_str = r#"
[flavor-overrides]
"README.md" = "gfm"
"docs/**/*.md" = "quarto"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.flavor_overrides.get("README.md"), Some(&Flavor::Gfm));
assert_eq!(
cfg.flavor_overrides.get("docs/**/*.md"),
Some(&Flavor::Quarto)
);
}
#[test]
fn flavor_override_uses_most_specific_match() {
let mut overrides = HashMap::new();
overrides.insert("docs/**/*.md".to_string(), Flavor::Pandoc);
overrides.insert("docs/README.md".to_string(), Flavor::Gfm);
let input = Path::new("/project/docs/README.md");
let flavor = detect_flavor_override(input, Some(Path::new("/project")), &overrides);
assert_eq!(flavor, Some(Flavor::Gfm));
}
#[test]
fn detect_flavor_uses_override_for_markdown_family() {
let mut cfg = Config {
flavor: Flavor::Pandoc,
..Config::default()
};
cfg.flavor_overrides
.insert("README.md".to_string(), Flavor::Gfm);
let flavor = detect_flavor(
Some(Path::new("/project/README.md")),
Some(Path::new("/project/panache.toml")),
&cfg,
);
assert_eq!(flavor, Some(Flavor::Gfm));
}
#[test]
fn detect_flavor_keeps_qmd_rmd_extension_defaults() {
let mut cfg = Config {
flavor: Flavor::Gfm,
..Config::default()
};
cfg.flavor_overrides
.insert("docs/**/*.qmd".to_string(), Flavor::Pandoc);
cfg.flavor_overrides
.insert("docs/**/*.Rmd".to_string(), Flavor::Quarto);
let qmd_flavor = detect_flavor(
Some(Path::new("/project/docs/chapter.qmd")),
Some(Path::new("/project/panache.toml")),
&cfg,
);
let rmd_flavor = detect_flavor(
Some(Path::new("/project/docs/chapter.Rmd")),
Some(Path::new("/project/panache.toml")),
&cfg,
);
assert_eq!(qmd_flavor, Some(Flavor::Quarto));
assert_eq!(rmd_flavor, Some(Flavor::RMarkdown));
}
#[test]
fn user_config_adds_formatters() {
let toml_str = r#"
[formatters.r]
cmd = "custom"
args = ["--flag"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.formatters.len(), 1);
let r_fmt = &cfg.formatters.get("r").unwrap()[0];
assert_eq!(r_fmt.cmd, "custom");
assert_eq!(r_fmt.args, vec!["--flag"]);
}
#[test]
fn empty_formatters_section_stays_empty() {
let toml_str = r#"
line_width = 100
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.formatters.is_empty());
}
#[test]
fn preset_with_enabled_false() {
let toml_str = r#"
[formatters.r]
preset = "air"
enabled = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("r"));
}
#[test]
fn default_flavor_is_pandoc() {
let default_cfg = Config::default();
assert_eq!(default_cfg.flavor, Flavor::Pandoc);
}
#[test]
fn extensions_merge_with_flavor_quarto() {
let toml_str = r#"
flavor = "quarto"
[extensions]
quarto-crossrefs = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.quarto_crossrefs);
assert!(cfg.extensions.quarto_callouts);
assert!(cfg.extensions.quarto_shortcodes);
assert!(cfg.extensions.citations);
assert!(cfg.extensions.yaml_metadata_block);
assert!(cfg.extensions.fenced_divs);
}
#[test]
fn extensions_merge_with_flavor_pandoc() {
let toml_str = r#"
flavor = "pandoc"
[extensions]
citations = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.citations);
assert!(cfg.extensions.yaml_metadata_block);
assert!(cfg.extensions.fenced_divs);
assert!(!cfg.extensions.quarto_crossrefs);
assert!(!cfg.extensions.quarto_callouts);
}
#[test]
fn extensions_no_override_uses_flavor_defaults() {
let toml_str = r#"
flavor = "quarto"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.quarto_crossrefs);
assert!(cfg.extensions.quarto_callouts);
assert!(cfg.extensions.quarto_shortcodes);
}
#[test]
fn extensions_empty_section_uses_flavor_defaults() {
let toml_str = r#"
flavor = "quarto"
[extensions]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.quarto_crossrefs);
assert!(cfg.extensions.quarto_callouts);
assert!(cfg.extensions.quarto_shortcodes);
}
#[test]
fn extensions_multiple_overrides() {
let toml_str = r#"
flavor = "quarto"
[extensions]
quarto-crossrefs = false
citations = false
emoji = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.quarto_crossrefs);
assert!(!cfg.extensions.citations);
assert!(cfg.extensions.emoji);
assert!(cfg.extensions.quarto_callouts);
assert!(cfg.extensions.quarto_shortcodes);
}
#[test]
fn extensions_per_flavor_override_wins_over_global() {
let toml_str = r#"
flavor = "gfm"
[extensions]
task-lists = false
citations = true
[extensions.gfm]
task-lists = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.task_lists);
assert!(cfg.extensions.citations);
}
#[test]
fn extensions_per_flavor_table_is_ignored_for_other_flavors() {
let toml_str = r#"
flavor = "pandoc"
[extensions]
citations = false
[extensions.gfm]
citations = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.citations);
}
#[test]
fn extensions_per_flavor_commonmark_alias_works() {
let toml_str = r#"
flavor = "common-mark"
[extensions]
citations = true
[extensions.commonmark]
citations = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.citations);
}
#[test]
fn multimarkdown_flavor_enables_mmd_header_identifiers_by_default() {
let cfg = toml::from_str::<Config>("flavor = \"multimarkdown\"").unwrap();
assert_eq!(cfg.flavor, Flavor::MultiMarkdown);
assert!(cfg.extensions.mmd_header_identifiers);
assert!(cfg.extensions.mmd_title_block);
assert!(cfg.extensions.mmd_link_attributes);
assert!(!cfg.extensions.pandoc_title_block);
assert!(cfg.extensions.tex_math_double_backslash);
assert!(cfg.extensions.definition_lists);
assert!(cfg.extensions.raw_attribute);
}
#[test]
fn extensions_per_flavor_multimarkdown_table_works() {
let toml_str = r#"
flavor = "multimarkdown"
[extensions]
mmd-header-identifiers = false
[extensions.multimarkdown]
mmd-header-identifiers = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.mmd_header_identifiers);
}
#[test]
fn extensions_per_flavor_multimarkdown_title_block_override_works() {
let toml_str = r#"
flavor = "multimarkdown"
[extensions]
mmd-title-block = false
[extensions.multimarkdown]
mmd-title-block = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.mmd_title_block);
}
#[test]
fn extensions_per_flavor_multimarkdown_link_attributes_override_works() {
let toml_str = r#"
flavor = "multimarkdown"
[extensions]
mmd-link-attributes = false
[extensions.multimarkdown]
mmd-link-attributes = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(cfg.extensions.mmd_link_attributes);
}
#[test]
fn alerts_enabled_by_default_for_gfm() {
let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(cfg.extensions.alerts);
}
#[test]
fn auto_identifiers_enabled_by_default_for_pandoc_and_gfm() {
let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(pandoc.extensions.auto_identifiers);
assert!(gfm.extensions.auto_identifiers);
}
#[test]
fn gfm_auto_identifiers_enabled_by_default_only_for_gfm() {
let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
assert!(!pandoc.extensions.gfm_auto_identifiers);
assert!(gfm.extensions.gfm_auto_identifiers);
assert!(!commonmark.extensions.gfm_auto_identifiers);
}
#[test]
fn footnotes_enabled_by_default_for_gfm() {
let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(cfg.extensions.footnotes);
}
#[test]
fn fenced_code_blocks_enabled_by_default_for_gfm() {
let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(cfg.extensions.backtick_code_blocks);
assert!(cfg.extensions.fenced_code_blocks);
}
#[test]
fn tex_math_gfm_enabled_by_default_for_gfm() {
let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(cfg.extensions.tex_math_gfm);
}
#[test]
fn executable_code_enabled_by_default_for_quarto_and_rmarkdown() {
let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
assert!(quarto.extensions.executable_code);
assert!(rmarkdown.extensions.executable_code);
assert!(quarto.extensions.quarto_inline_code);
assert!(quarto.extensions.rmarkdown_inline_code);
assert!(rmarkdown.extensions.rmarkdown_inline_code);
assert!(!rmarkdown.extensions.quarto_inline_code);
}
#[test]
fn bookdown_equation_references_enabled_by_default_only_for_rmarkdown() {
let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
assert!(!pandoc.extensions.bookdown_equation_references);
assert!(!quarto.extensions.bookdown_equation_references);
assert!(rmarkdown.extensions.bookdown_equation_references);
}
#[test]
fn executable_code_disabled_by_default_for_pandoc_gfm_and_commonmark() {
let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
assert!(!pandoc.extensions.executable_code);
assert!(!gfm.extensions.executable_code);
assert!(!commonmark.extensions.executable_code);
assert!(!pandoc.extensions.quarto_inline_code);
assert!(!pandoc.extensions.rmarkdown_inline_code);
assert!(!gfm.extensions.quarto_inline_code);
assert!(!gfm.extensions.rmarkdown_inline_code);
assert!(!commonmark.extensions.quarto_inline_code);
assert!(!commonmark.extensions.rmarkdown_inline_code);
}
#[test]
fn gfm_disables_non_gfm_pandoc_extensions() {
let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
assert!(!cfg.extensions.citations);
assert!(!cfg.extensions.definition_lists);
assert!(!cfg.extensions.fenced_divs);
assert!(!cfg.extensions.raw_tex);
}
#[test]
fn commonmark_defaults_match_minimal_set() {
let cfg = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
assert!(cfg.extensions.raw_html);
assert!(!cfg.extensions.auto_identifiers);
assert!(!cfg.extensions.autolinks);
assert!(!cfg.extensions.inline_links);
assert!(!cfg.extensions.reference_links);
}
#[test]
fn alerts_disabled_by_default_for_pandoc() {
let cfg = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
assert!(!cfg.extensions.alerts);
}
#[test]
fn format_section_new_format() {
let toml_str = r#"
flavor = "quarto"
[format]
wrap = "sentence"
built-in-greedy-wrap = true
blank-lines = "collapse"
math-delimiter-style = "dollars"
math-indent = 2
tab-stops = "preserve"
tab-width = 4
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
assert_eq!(cfg.blank_lines, BlankLines::Collapse);
assert_eq!(cfg.math_delimiter_style, MathDelimiterStyle::Dollars);
assert_eq!(cfg.math_indent, 2);
assert_eq!(cfg.tab_stops, TabStopMode::Preserve);
assert_eq!(cfg.tab_width, 4);
assert!(cfg.built_in_greedy_wrap);
}
#[test]
fn format_section_with_deprecated_code_blocks_is_accepted() {
let toml_str = r#"
flavor = "pandoc"
[format]
wrap = "preserve"
[format.code-blocks]
fence-style = "tilde"
attribute-style = "explicit"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
assert!(cfg.built_in_greedy_wrap);
}
#[test]
fn backwards_compat_old_format_still_works() {
let toml_str = r#"
flavor = "quarto"
wrap = "reflow"
math-indent = 4
[code-blocks]
fence-style = "backtick"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
assert_eq!(cfg.math_indent, 4);
}
#[test]
fn format_section_takes_precedence() {
let toml_str = r#"
flavor = "quarto"
# Old format (should be ignored)
wrap = "preserve"
math-indent = 10
# New format (should take precedence)
[format]
wrap = "sentence"
math-indent = 2
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
assert_eq!(cfg.math_indent, 2);
}
#[test]
fn deprecated_style_section_still_supported() {
let toml_str = r#"
[style]
wrap = "preserve"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
}
}
#[cfg(test)]
mod line_ending_test {
use super::*;
#[test]
fn test_deserialize_line_ending_in_config() {
#[derive(Deserialize)]
struct TestConfig {
line_ending: LineEnding,
}
let cfg: TestConfig = toml::from_str(r#"line_ending = "lf""#).unwrap();
assert_eq!(cfg.line_ending, LineEnding::Lf);
let cfg2: TestConfig = toml::from_str(r#"line_ending = "auto""#).unwrap();
assert_eq!(cfg2.line_ending, LineEnding::Auto);
let cfg3: TestConfig = toml::from_str(r#"line_ending = "crlf""#).unwrap();
assert_eq!(cfg3.line_ending, LineEnding::Crlf);
}
}
#[cfg(test)]
mod raw_config_test {
use super::*;
#[test]
fn test_raw_config_line_ending() {
let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
let content = r#"
line-ending = "crlf"
line-width = 100
"#;
let cfg2: Config = toml::from_str(content).unwrap();
assert_eq!(cfg2.line_ending, Some(LineEnding::Crlf));
assert_eq!(cfg2.line_width, 100);
}
}
#[cfg(test)]
mod field_name_test {
use super::*;
#[test]
fn test_line_ending_field_name() {
let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
let cfg_auto: Config = toml::from_str(r#"line-ending = "auto""#).unwrap();
assert_eq!(cfg_auto.line_ending, Some(LineEnding::Auto));
let cfg_crlf: Config = toml::from_str(r#"line-ending = "crlf""#).unwrap();
assert_eq!(cfg_crlf.line_ending, Some(LineEnding::Crlf));
}
}
#[cfg(test)]
mod code_blocks_config_test {
use super::*;
#[test]
fn deprecated_top_level_code_blocks_is_accepted_as_noop() {
let toml_str = r#"
flavor = "pandoc"
[code-blocks]
attribute-style = "explicit"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.flavor, Flavor::Pandoc);
}
#[test]
fn deprecated_format_code_blocks_is_accepted_as_noop() {
let toml_str = r#"
flavor = "quarto"
[format]
wrap = "reflow"
[format.code-blocks]
attribute-style = "explicit"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
}
#[test]
fn no_code_blocks_config_still_parses() {
let toml_str = r#"
flavor = "quarto"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.flavor, Flavor::Quarto);
}
#[test]
fn new_format_single_formatter() {
let toml_str = r#"
[formatters]
r = "air"
python = "black"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.formatters.len(), 2);
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
let py_fmts = cfg.formatters.get("python").unwrap();
assert_eq!(py_fmts.len(), 1);
assert_eq!(py_fmts[0].cmd, "black");
}
#[test]
fn new_format_multiple_formatters() {
let toml_str = r#"
[formatters]
python = ["ruff", "black"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let py_fmts = cfg.formatters.get("python").unwrap();
assert_eq!(py_fmts.len(), 2);
assert_eq!(py_fmts[0].cmd, "ruff");
assert_eq!(py_fmts[1].cmd, "black");
}
#[test]
fn new_format_with_custom_definition() {
let toml_str = r#"
[formatters]
r = "custom-air"
[formatters.custom-air]
cmd = "air"
args = ["format", "--custom-flag"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag"]);
}
#[test]
fn new_format_empty_array() {
let toml_str = r#"
[formatters]
r = []
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("r"));
}
#[test]
fn new_format_reusable_definition() {
let toml_str = r#"
[formatters]
javascript = "prettier"
typescript = "prettier"
json = "prettier"
[formatters.prettier]
cmd = "prettier"
args = ["--print-width=100"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert_eq!(cfg.formatters.len(), 3);
for lang in ["javascript", "typescript", "json"] {
let fmts = cfg.formatters.get(lang).unwrap();
assert_eq!(fmts.len(), 1);
assert_eq!(fmts[0].cmd, "prettier");
assert_eq!(fmts[0].args, vec!["--print-width=100"]);
}
}
#[test]
fn preset_inheritance_override_only_args() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
args = ["format", "--custom-flag", "{}"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert!(!r_fmts[0].stdin);
assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag", "{}"]);
}
#[test]
fn preset_inheritance_override_only_cmd() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
cmd = "custom-air"
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "custom-air");
assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
assert!(!r_fmts[0].stdin);
}
#[test]
fn preset_inheritance_override_only_stdin() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
stdin = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
assert!(r_fmts[0].stdin);
}
#[test]
fn preset_inheritance_override_multiple_fields() {
let toml_str = r#"
[formatters]
python = "black"
[formatters.black]
args = ["--line-length=100"]
stdin = false
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let py_fmts = cfg.formatters.get("python").unwrap();
assert_eq!(py_fmts.len(), 1);
assert_eq!(py_fmts[0].cmd, "black");
assert_eq!(py_fmts[0].args, vec!["--line-length=100"]);
assert!(!py_fmts[0].stdin);
}
#[test]
fn preset_inheritance_override_all_fields() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
cmd = "totally-different"
args = ["custom"]
stdin = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "totally-different");
assert_eq!(r_fmts[0].args, vec!["custom"]);
assert!(r_fmts[0].stdin);
}
#[test]
fn preset_inheritance_empty_definition_uses_preset() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
# Empty definition - should use preset as-is
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
assert!(!r_fmts[0].stdin);
}
#[test]
fn preset_inheritance_unknown_name_without_cmd_errors() {
let toml_str = r#"
[formatters]
r = "unknown-formatter"
[formatters.unknown-formatter]
args = ["--flag"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.formatters.contains_key("r"));
}
#[test]
fn preset_inheritance_unknown_name_with_cmd_works() {
let toml_str = r#"
[formatters]
r = "unknown-formatter"
[formatters.unknown-formatter]
cmd = "my-custom-formatter"
args = ["--flag"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "my-custom-formatter");
assert_eq!(r_fmts[0].args, vec!["--flag"]);
assert!(r_fmts[0].stdin); }
#[test]
fn append_args_with_preset_inheritance() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
append-args = ["-i", "2"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["format", "{}", "-i", "2"]);
assert!(!r_fmts[0].stdin);
}
#[test]
fn prepend_args_with_preset_inheritance() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
prepend-args = ["--verbose"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "air");
assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}"]);
assert!(!r_fmts[0].stdin);
}
#[test]
fn both_prepend_and_append_args() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
prepend_args = ["--verbose"]
append_args = ["-i", "2"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}", "-i", "2"]);
}
#[test]
fn append_args_with_explicit_args() {
let toml_str = r#"
[formatters]
r = "custom"
[formatters.custom]
cmd = "shfmt"
args = ["-filename", "$FILENAME"]
append_args = ["-i", "2"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].cmd, "shfmt");
assert_eq!(r_fmts[0].args, vec!["-filename", "$FILENAME", "-i", "2"]);
}
#[test]
fn prepend_args_with_explicit_args() {
let toml_str = r#"
[formatters]
r = "custom"
[formatters.custom]
cmd = "formatter"
args = ["input.txt"]
prepend_args = ["--config", "cfg.toml"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].args, vec!["--config", "cfg.toml", "input.txt"]);
}
#[test]
fn args_override_with_append_still_applies() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
args = ["custom", "override"]
append_args = ["--extra"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].args, vec!["custom", "override", "--extra"]);
}
#[test]
fn empty_append_prepend_arrays() {
let toml_str = r#"
[formatters]
r = "air"
[formatters.air]
prepend_args = []
append_args = []
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
}
#[test]
fn modifiers_without_base_args() {
let toml_str = r#"
[formatters]
r = "custom"
[formatters.custom]
cmd = "formatter"
prepend_args = ["--flag"]
append_args = ["--other"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let r_fmts = cfg.formatters.get("r").unwrap();
assert_eq!(r_fmts.len(), 1);
assert_eq!(r_fmts[0].args, vec!["--flag", "--other"]);
}
}
#[cfg(test)]
mod parser_config_test {
use super::*;
#[test]
fn test_parser_config_default() {
let cfg = Config::default();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
}
#[test]
fn test_parser_config_empty() {
let toml_str = r#"
[parser]
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
}
#[test]
fn test_parser_config_pandoc_compat_latest() {
let toml_str = r#"
pandoc-compat = "latest"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
assert_eq!(cfg.parser.effective_pandoc_compat(), PandocCompat::V3_9);
}
#[test]
fn test_parser_config_pandoc_compat_accepts_version_aliases() {
for value in ["3.7", "3-7", "v3.7", "v3-7"] {
let toml_str = format!("pandoc-compat = \"{}\"\n", value);
let cfg: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
}
for value in ["3.9", "3-9", "v3.9", "v3-9"] {
let toml_str = format!("pandoc-compat = \"{}\"\n", value);
let cfg: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
}
}
#[test]
fn test_parser_config_pandoc_compat_parser_section_backwards_compat() {
let toml_str = r#"
[parser]
pandoc-compat = "latest"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
}
#[test]
fn test_parser_config_pandoc_compat_top_level_takes_precedence() {
let toml_str = r#"
pandoc-compat = "3.7"
[parser]
pandoc-compat = "latest"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
}
}
#[test]
fn test_snake_case_alias_backwards_compat() {
let toml_str = r#"
flavor = "quarto"
[extensions]
quarto_crossrefs = false
tex_math_dollars = true
tex_math_gfm = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.quarto_crossrefs);
assert!(cfg.extensions.tex_math_dollars);
assert!(cfg.extensions.tex_math_gfm);
}
#[test]
fn test_kebab_case_new_format() {
let toml_str = r#"
flavor = "quarto"
[extensions]
quarto-crossrefs = false
tex-math-dollars = true
tex-math-gfm = true
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
assert!(!cfg.extensions.quarto_crossrefs);
assert!(cfg.extensions.tex_math_dollars);
assert!(cfg.extensions.tex_math_gfm);
}
#[test]
fn test_formatter_prepend_append_args_snake_case() {
let toml_str = r#"
[formatters.test]
cmd = "test"
args = ["--middle"]
prepend_args = ["--before"]
append_args = ["--after"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("test").unwrap()[0];
assert_eq!(fmt.cmd, "test");
assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
}
#[test]
fn test_formatter_prepend_append_args_kebab_case() {
let toml_str = r#"
[formatters.test]
cmd = "test"
args = ["--middle"]
prepend-args = ["--before"]
append-args = ["--after"]
"#;
let cfg = toml::from_str::<Config>(toml_str).unwrap();
let fmt = &cfg.formatters.get("test").unwrap()[0];
assert_eq!(fmt.cmd, "test");
assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
}