1use std::collections::HashMap;
2use std::env;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use globset::GlobBuilder;
8use serde::{Deserialize, Deserializer, Serialize};
9
10mod formatter_presets;
11pub use formatter_presets::FormatterPresetMetadata;
12
13#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
16#[serde(rename_all = "kebab-case")]
17pub enum Flavor {
18 #[default]
20 Pandoc,
21 Quarto,
23 #[serde(rename = "rmarkdown")]
25 RMarkdown,
26 Gfm,
28 CommonMark,
30 #[serde(rename = "multimarkdown")]
32 MultiMarkdown,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(default)]
40#[serde(rename_all = "kebab-case")]
41pub struct Extensions {
42 #[serde(alias = "blank_before_header")]
47 pub blank_before_header: bool,
48 #[serde(alias = "header_attributes")]
50 pub header_attributes: bool,
51 pub auto_identifiers: bool,
53 pub gfm_auto_identifiers: bool,
55 pub implicit_header_references: bool,
57
58 #[serde(alias = "blank_before_blockquote")]
61 pub blank_before_blockquote: bool,
62
63 #[serde(alias = "fancy_lists")]
66 pub fancy_lists: bool,
67 pub startnum: bool,
69 #[serde(alias = "example_lists")]
71 pub example_lists: bool,
72 #[serde(alias = "task_lists")]
74 pub task_lists: bool,
75 #[serde(alias = "definition_lists")]
77 pub definition_lists: bool,
78
79 #[serde(alias = "backtick_code_blocks")]
82 pub backtick_code_blocks: bool,
83 #[serde(alias = "fenced_code_blocks")]
85 pub fenced_code_blocks: bool,
86 #[serde(alias = "fenced_code_attributes")]
88 pub fenced_code_attributes: bool,
89 pub executable_code: bool,
91 pub rmarkdown_inline_code: bool,
93 pub quarto_inline_code: bool,
95 #[serde(alias = "inline_code_attributes")]
97 pub inline_code_attributes: bool,
98
99 #[serde(alias = "simple_tables")]
102 pub simple_tables: bool,
103 #[serde(alias = "multiline_tables")]
105 pub multiline_tables: bool,
106 #[serde(alias = "grid_tables")]
108 pub grid_tables: bool,
109 #[serde(alias = "pipe_tables")]
111 pub pipe_tables: bool,
112 #[serde(alias = "table_captions")]
114 pub table_captions: bool,
115
116 #[serde(alias = "fenced_divs")]
119 pub fenced_divs: bool,
120 #[serde(alias = "native_divs")]
122 pub native_divs: bool,
123
124 #[serde(alias = "line_blocks")]
127 pub line_blocks: bool,
128
129 #[serde(alias = "intraword_underscores")]
134 pub intraword_underscores: bool,
135 pub strikeout: bool,
137 pub superscript: bool,
139 pub subscript: bool,
140
141 #[serde(alias = "inline_links")]
144 pub inline_links: bool,
145 #[serde(alias = "reference_links")]
147 pub reference_links: bool,
148 #[serde(alias = "shortcut_reference_links")]
150 pub shortcut_reference_links: bool,
151 #[serde(alias = "link_attributes")]
153 pub link_attributes: bool,
154 pub autolinks: bool,
156
157 #[serde(alias = "inline_images")]
160 pub inline_images: bool,
161 #[serde(alias = "implicit_figures")]
163 pub implicit_figures: bool,
164
165 #[serde(alias = "tex_math_dollars")]
168 pub tex_math_dollars: bool,
169 #[serde(alias = "tex_math_gfm")]
171 pub tex_math_gfm: bool,
172 #[serde(alias = "tex_math_single_backslash")]
174 pub tex_math_single_backslash: bool,
175 #[serde(alias = "tex_math_double_backslash")]
177 pub tex_math_double_backslash: bool,
178
179 #[serde(alias = "inline_footnotes")]
182 pub inline_footnotes: bool,
183 pub footnotes: bool,
185
186 pub citations: bool,
189
190 #[serde(alias = "bracketed_spans")]
193 pub bracketed_spans: bool,
194 #[serde(alias = "native_spans")]
196 pub native_spans: bool,
197
198 #[serde(alias = "yaml_metadata_block")]
201 pub yaml_metadata_block: bool,
202 #[serde(alias = "pandoc_title_block")]
204 pub pandoc_title_block: bool,
205 pub mmd_title_block: bool,
207
208 #[serde(alias = "raw_html")]
211 pub raw_html: bool,
212 #[serde(alias = "markdown_in_html_blocks")]
214 pub markdown_in_html_blocks: bool,
215 #[serde(alias = "raw_tex")]
217 pub raw_tex: bool,
218 #[serde(alias = "raw_attribute")]
220 pub raw_attribute: bool,
221
222 #[serde(alias = "all_symbols_escapable")]
225 pub all_symbols_escapable: bool,
226 #[serde(alias = "escaped_line_breaks")]
228 pub escaped_line_breaks: bool,
229
230 #[serde(alias = "autolink_bare_uris")]
234 pub autolink_bare_uris: bool,
235 #[serde(alias = "hard_line_breaks")]
237 pub hard_line_breaks: bool,
238 pub mmd_header_identifiers: bool,
240 pub mmd_link_attributes: bool,
242 pub alerts: bool,
244 pub emoji: bool,
246 pub mark: bool,
248
249 #[serde(alias = "quarto_callouts")]
252 pub quarto_callouts: bool,
253 #[serde(alias = "quarto_crossrefs")]
255 pub quarto_crossrefs: bool,
256 #[serde(alias = "quarto_shortcodes")]
258 pub quarto_shortcodes: bool,
259 pub bookdown_references: bool,
261 pub bookdown_equation_references: bool,
263}
264
265impl Default for Extensions {
266 fn default() -> Self {
267 Self::for_flavor(Flavor::default())
268 }
269}
270
271impl Extensions {
272 fn none_defaults() -> Self {
273 Self {
274 alerts: false,
275 all_symbols_escapable: false,
276 auto_identifiers: false,
277 autolink_bare_uris: false,
278 autolinks: false,
279 backtick_code_blocks: false,
280 blank_before_blockquote: false,
281 blank_before_header: false,
282 bookdown_references: false,
283 bookdown_equation_references: false,
284 bracketed_spans: false,
285 citations: false,
286 definition_lists: false,
287 emoji: false,
288 escaped_line_breaks: false,
289 example_lists: false,
290 executable_code: false,
291 rmarkdown_inline_code: false,
292 quarto_inline_code: false,
293 fancy_lists: false,
294 fenced_code_attributes: false,
295 fenced_code_blocks: false,
296 fenced_divs: false,
297 footnotes: false,
298 gfm_auto_identifiers: false,
299 grid_tables: false,
300 hard_line_breaks: false,
301 header_attributes: false,
302 implicit_figures: false,
303 implicit_header_references: false,
304 inline_code_attributes: false,
305 inline_footnotes: false,
306 inline_images: false,
307 inline_links: false,
308 intraword_underscores: false,
309 line_blocks: false,
310 link_attributes: false,
311 mark: false,
312 markdown_in_html_blocks: false,
313 mmd_header_identifiers: false,
314 mmd_link_attributes: false,
315 mmd_title_block: false,
316 multiline_tables: false,
317 native_divs: false,
318 native_spans: false,
319 pandoc_title_block: false,
320 pipe_tables: false,
321 quarto_callouts: false,
322 quarto_crossrefs: false,
323 quarto_shortcodes: false,
324 raw_attribute: false,
325 raw_html: false,
326 raw_tex: false,
327 reference_links: false,
328 shortcut_reference_links: false,
329 simple_tables: false,
330 startnum: false,
331 strikeout: false,
332 subscript: false,
333 superscript: false,
334 table_captions: false,
335 task_lists: false,
336 tex_math_dollars: false,
337 tex_math_double_backslash: false,
338 tex_math_gfm: false,
339 tex_math_single_backslash: false,
340 yaml_metadata_block: false,
341 }
342 }
343
344 pub fn for_flavor(flavor: Flavor) -> Self {
346 match flavor {
347 Flavor::Pandoc => Self::pandoc_defaults(),
348 Flavor::Quarto => Self::quarto_defaults(),
349 Flavor::RMarkdown => Self::rmarkdown_defaults(),
350 Flavor::Gfm => Self::gfm_defaults(),
351 Flavor::CommonMark => Self::commonmark_defaults(),
352 Flavor::MultiMarkdown => Self::multimarkdown_defaults(),
353 }
354 }
355
356 fn pandoc_defaults() -> Self {
357 Self {
358 auto_identifiers: true,
360 blank_before_blockquote: true,
361 blank_before_header: true,
362 gfm_auto_identifiers: false,
363 header_attributes: true,
364 implicit_header_references: true,
365
366 definition_lists: true,
368 example_lists: true,
369 fancy_lists: true,
370 startnum: true,
371 task_lists: true,
372
373 backtick_code_blocks: true,
375 executable_code: false,
376 rmarkdown_inline_code: false,
377 quarto_inline_code: false,
378 fenced_code_attributes: true,
379 fenced_code_blocks: true,
380 inline_code_attributes: true,
381
382 grid_tables: true,
384 multiline_tables: true,
385 pipe_tables: true,
386 simple_tables: true,
387 table_captions: true,
388
389 fenced_divs: true,
391 native_divs: true,
392
393 line_blocks: true,
395
396 intraword_underscores: true,
398 strikeout: true,
399 subscript: true,
400 superscript: true,
401
402 autolinks: true,
404 inline_links: true,
405 link_attributes: true,
406 reference_links: true,
407 shortcut_reference_links: true,
408
409 implicit_figures: true,
411 inline_images: true,
412
413 tex_math_dollars: true,
415 tex_math_double_backslash: false,
416 tex_math_gfm: false,
417 tex_math_single_backslash: false,
418
419 footnotes: true,
421 inline_footnotes: true,
422
423 citations: true,
425
426 bracketed_spans: true,
428 native_spans: true,
429
430 mmd_title_block: false,
432 pandoc_title_block: true,
433 yaml_metadata_block: true,
434
435 markdown_in_html_blocks: false,
437 raw_attribute: true,
438 raw_html: true,
439 raw_tex: true,
440
441 all_symbols_escapable: true,
443 escaped_line_breaks: true,
444
445 alerts: false,
447 autolink_bare_uris: false,
448 emoji: false,
449 hard_line_breaks: false,
450 mark: false,
451 mmd_header_identifiers: false,
452 mmd_link_attributes: false,
453
454 bookdown_references: false,
456 bookdown_equation_references: false,
457 quarto_callouts: false,
458 quarto_crossrefs: false,
459 quarto_shortcodes: false,
460 }
461 }
462
463 fn quarto_defaults() -> Self {
464 let mut ext = Self::pandoc_defaults();
465
466 ext.executable_code = true;
467 ext.rmarkdown_inline_code = true;
468 ext.quarto_inline_code = true;
469 ext.quarto_callouts = true;
470 ext.quarto_crossrefs = true;
471 ext.quarto_shortcodes = true;
472
473 ext
474 }
475
476 fn rmarkdown_defaults() -> Self {
477 let mut ext = Self::pandoc_defaults();
478
479 ext.bookdown_references = true;
480 ext.bookdown_equation_references = true;
481 ext.executable_code = true;
482 ext.rmarkdown_inline_code = true;
483 ext.quarto_inline_code = false;
484 ext.tex_math_dollars = true;
485 ext.tex_math_single_backslash = true;
486
487 ext
488 }
489
490 fn gfm_defaults() -> Self {
491 let mut ext = Self::none_defaults();
492
493 ext.alerts = true;
494 ext.auto_identifiers = true;
495 ext.autolink_bare_uris = true;
496 ext.backtick_code_blocks = true;
497 ext.emoji = true;
498 ext.fenced_code_blocks = true;
499 ext.footnotes = true;
500 ext.gfm_auto_identifiers = true;
501 ext.pipe_tables = true;
502 ext.raw_html = true;
503 ext.strikeout = true;
504 ext.task_lists = true;
505 ext.tex_math_dollars = true;
506 ext.tex_math_gfm = true;
507 ext.yaml_metadata_block = true;
508
509 ext
510 }
511
512 fn commonmark_defaults() -> Self {
513 let mut ext = Self::none_defaults();
514 ext.raw_html = true;
515 ext
516 }
517
518 fn multimarkdown_defaults() -> Self {
519 let mut ext = Self::none_defaults();
520
521 ext.all_symbols_escapable = true;
522 ext.auto_identifiers = true;
523 ext.backtick_code_blocks = true;
524 ext.definition_lists = true;
525 ext.footnotes = true;
526 ext.implicit_figures = true;
527 ext.implicit_header_references = true;
528 ext.intraword_underscores = true;
529 ext.mmd_header_identifiers = true;
530 ext.mmd_link_attributes = true;
531 ext.mmd_title_block = true;
532 ext.pipe_tables = true;
533 ext.raw_attribute = true;
534 ext.raw_html = true;
535 ext.reference_links = true;
536 ext.shortcut_reference_links = true;
537 ext.subscript = true;
538 ext.superscript = true;
539 ext.tex_math_dollars = true;
540 ext.tex_math_double_backslash = true;
541
542 ext
543 }
544
545 pub fn merge_with_flavor(user_overrides: HashMap<String, bool>, flavor: Flavor) -> Self {
559 let defaults = Self::for_flavor(flavor);
560 Self::merge_overrides(defaults, user_overrides)
561 }
562
563 fn merge_overrides(base: Extensions, user_overrides: HashMap<String, bool>) -> Self {
564 use serde_json::{Map, Value};
565
566 let defaults_value =
567 serde_json::to_value(&base).expect("Failed to serialize extension defaults");
568
569 let mut merged = if let Value::Object(obj) = defaults_value {
570 obj
571 } else {
572 Map::new()
573 };
574
575 for (key, value) in user_overrides {
577 let normalized_key = key.replace('_', "-");
579 merged.insert(normalized_key, Value::Bool(value));
580 }
581
582 serde_json::from_value(Value::Object(merged))
584 .expect("Failed to deserialize merged extensions")
585 }
586}
587
588#[derive(Debug, Clone, PartialEq)]
590pub struct FormatterConfig {
591 pub cmd: String,
593 pub args: Vec<String>,
595 pub enabled: bool,
597 pub stdin: bool,
599}
600
601#[derive(Debug, Clone, Deserialize, PartialEq)]
603#[serde(untagged)]
604pub enum FormatterValue {
605 Single(String),
607 Multiple(Vec<String>),
609}
610
611#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
629#[serde(default)]
630#[serde(rename_all = "kebab-case")]
631pub struct FormatterDefinition {
632 pub preset: Option<String>,
635 pub cmd: Option<String>,
637 pub args: Option<Vec<String>>,
639 #[serde(alias = "prepend_args")]
641 pub prepend_args: Option<Vec<String>>,
642 #[serde(alias = "append_args")]
644 pub append_args: Option<Vec<String>>,
645 pub stdin: Option<bool>,
647 pub enabled: Option<bool>,
649}
650
651#[derive(Debug, Deserialize)]
653#[serde(default)]
654struct RawFormatterConfig {
655 preset: Option<String>,
657 cmd: Option<String>,
659 args: Option<Vec<String>>,
661 enabled: bool,
663 stdin: bool,
665}
666
667impl Default for RawFormatterConfig {
668 fn default() -> Self {
669 Self {
670 preset: None,
671 cmd: None,
672 args: None,
673 enabled: true,
674 stdin: true,
675 }
676 }
677}
678
679impl<'de> Deserialize<'de> for FormatterConfig {
680 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
681 where
682 D: Deserializer<'de>,
683 {
684 let raw = RawFormatterConfig::deserialize(deserializer)?;
685
686 if raw.preset.is_some() && raw.cmd.is_some() {
688 return Err(serde::de::Error::custom(
689 "FormatterConfig: 'preset' and 'cmd' are mutually exclusive - use one or the other",
690 ));
691 }
692
693 if let Some(preset_name) = raw.preset {
695 let preset = get_formatter_preset(&preset_name).ok_or_else(|| {
696 let available = formatter_preset_names().join(", ");
697 serde::de::Error::custom(format!(
698 "Unknown formatter preset: '{}'. Available presets: {}",
699 preset_name, available
700 ))
701 })?;
702
703 Ok(FormatterConfig {
705 cmd: preset.cmd,
706 args: preset.args,
707 enabled: raw.enabled,
708 stdin: preset.stdin,
709 })
710 } else if let Some(cmd) = raw.cmd {
711 Ok(FormatterConfig {
713 cmd,
714 args: raw.args.unwrap_or_default(),
715 enabled: raw.enabled,
716 stdin: raw.stdin,
717 })
718 } else {
719 Ok(FormatterConfig {
722 cmd: String::new(),
723 args: raw.args.unwrap_or_default(),
724 enabled: raw.enabled,
725 stdin: raw.stdin,
726 })
727 }
728 }
729}
730
731impl Default for FormatterConfig {
732 fn default() -> Self {
733 Self {
734 cmd: String::new(),
735 args: Vec::new(),
736 enabled: true,
737 stdin: true,
738 }
739 }
740}
741
742pub fn get_formatter_preset(name: &str) -> Option<FormatterConfig> {
745 formatter_presets::get_formatter_preset(name)
746}
747
748pub fn formatter_preset_names() -> &'static [&'static str] {
750 formatter_presets::formatter_preset_names()
751}
752
753pub fn formatter_preset_supported_languages(name: &str) -> Option<&'static [&'static str]> {
754 formatter_presets::formatter_preset_supported_languages(name)
755}
756
757pub fn formatter_preset_metadata(name: &str) -> Option<&'static FormatterPresetMetadata> {
758 formatter_presets::formatter_preset_metadata(name)
759}
760
761pub fn all_formatter_preset_metadata() -> &'static [FormatterPresetMetadata] {
762 formatter_presets::all_formatter_preset_metadata()
763}
764
765pub fn formatter_presets_for_language(language: &str) -> Vec<&'static FormatterPresetMetadata> {
766 formatter_presets::formatter_presets_for_language(language)
767}
768
769fn normalize_formatter_language(language: &str) -> String {
770 language.trim().to_ascii_lowercase().replace('_', "-")
771}
772
773fn validate_formatter_language_for_preset(lang: &str, formatter_name: &str) -> Result<(), String> {
774 let Some(supported) = formatter_preset_supported_languages(formatter_name) else {
775 return Ok(()); };
777
778 let normalized_lang = normalize_formatter_language(lang);
779 let matches = supported
780 .iter()
781 .any(|supported_lang| *supported_lang == normalized_lang);
782
783 if matches {
784 return Ok(());
785 }
786
787 Err(format!(
788 "Language '{}': formatter '{}' does not support this language. Supported languages: {}",
789 lang,
790 formatter_name,
791 supported.join(", ")
792 ))
793}
794
795pub fn default_formatters() -> HashMap<String, FormatterConfig> {
798 let mut map = HashMap::new();
799 map.insert("r".to_string(), get_formatter_preset("air").unwrap());
800 map.insert("python".to_string(), get_formatter_preset("ruff").unwrap());
801 map
802}
803
804#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
806#[serde(rename_all = "kebab-case")]
807pub enum MathDelimiterStyle {
808 #[default]
810 Preserve,
811 Dollars,
813 Backslash,
815}
816
817#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
819#[serde(rename_all = "kebab-case")]
820pub enum TabStopMode {
821 #[default]
823 Normalize,
824 Preserve,
826}
827
828#[derive(Debug, Clone, Deserialize, PartialEq)]
831#[serde(default)]
832#[serde(rename_all = "kebab-case")]
833pub struct StyleConfig {
834 pub wrap: Option<WrapMode>,
836 pub blank_lines: BlankLines,
838 pub math_delimiter_style: MathDelimiterStyle,
840 pub math_indent: usize,
842 pub tab_stops: TabStopMode,
844 pub tab_width: usize,
846 pub built_in_greedy_wrap: bool,
848}
849
850impl Default for StyleConfig {
851 fn default() -> Self {
852 Self {
853 wrap: Some(WrapMode::Reflow),
854 blank_lines: BlankLines::Collapse,
855 math_delimiter_style: MathDelimiterStyle::default(),
856 math_indent: 0,
857 tab_stops: TabStopMode::Normalize,
858 tab_width: 4,
859 built_in_greedy_wrap: true,
860 }
861 }
862}
863
864impl StyleConfig {
865 }
867
868#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
869pub enum PandocCompat {
870 #[serde(rename = "latest")]
875 Latest,
876 #[serde(rename = "3.7", alias = "3-7", alias = "v3.7", alias = "v3-7")]
878 V3_7,
879 #[default]
881 #[serde(rename = "3.9", alias = "3-9", alias = "v3.9", alias = "v3-9")]
882 V3_9,
883}
884
885impl PandocCompat {
886 pub const PINNED_LATEST: Self = Self::V3_9;
888
889 pub fn effective(self) -> Self {
890 match self {
891 Self::Latest => Self::PINNED_LATEST,
892 other => other,
893 }
894 }
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
899#[serde(default, rename_all = "kebab-case")]
900pub struct ParserConfig {
901 pub pandoc_compat: PandocCompat,
903}
904
905impl ParserConfig {
906 pub fn effective_pandoc_compat(&self) -> PandocCompat {
907 self.pandoc_compat.effective()
908 }
909}
910
911#[derive(Debug, Clone, Serialize, PartialEq, Default)]
915pub struct LintConfig {
916 pub rules: HashMap<String, bool>,
917}
918
919impl LintConfig {
920 fn normalize_rule_name(name: &str) -> String {
921 name.trim().to_lowercase().replace('_', "-")
922 }
923
924 fn normalize(mut self) -> Self {
925 self.rules = self
926 .rules
927 .into_iter()
928 .map(|(name, enabled)| (Self::normalize_rule_name(&name), enabled))
929 .collect();
930 self
931 }
932
933 pub fn is_rule_enabled(&self, rule_name: &str) -> bool {
934 let normalized = Self::normalize_rule_name(rule_name);
935 self.rules.get(&normalized).copied().unwrap_or(true)
936 }
937}
938
939impl<'de> Deserialize<'de> for LintConfig {
940 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
941 where
942 D: Deserializer<'de>,
943 {
944 let value = toml::Value::deserialize(deserializer)?;
945 let mut rules = HashMap::new();
946 let mut used_legacy_shape = false;
947
948 let mut table = value
949 .as_table()
950 .cloned()
951 .ok_or_else(|| serde::de::Error::custom("expected [lint] table"))?;
952
953 if let Some(rules_value) = table.remove("rules") {
955 let rules_table = rules_value
956 .as_table()
957 .ok_or_else(|| serde::de::Error::custom("[lint.rules] must be a table"))?;
958 for (name, enabled) in rules_table {
959 let enabled = enabled.as_bool().ok_or_else(|| {
960 serde::de::Error::custom(format!(
961 "[lint.rules] entry '{}' must be true or false",
962 name
963 ))
964 })?;
965 rules.insert(name.clone(), enabled);
966 }
967 }
968
969 for (name, enabled) in table {
971 let enabled = enabled.as_bool().ok_or_else(|| {
972 serde::de::Error::custom(format!(
973 "Unsupported [lint] key '{}'; use [lint.rules] for rule toggles",
974 name
975 ))
976 })?;
977 used_legacy_shape = true;
978 rules.insert(name, enabled);
979 }
980
981 if used_legacy_shape {
982 eprintln!(
983 "Warning: [lint] rule = true/false is deprecated; use [lint.rules] rule = true/false."
984 );
985 }
986
987 Ok(Self { rules }.normalize())
988 }
989}
990
991#[derive(Debug, Clone, Deserialize)]
993#[serde(rename_all = "kebab-case")]
994struct RawConfig {
995 #[serde(default)]
996 flavor: Flavor,
997 #[serde(default)]
998 extensions: Option<toml::Value>,
999 #[serde(default)]
1000 line_ending: Option<LineEnding>,
1001 #[serde(default = "default_line_width")]
1002 line_width: usize,
1003 #[serde(default)]
1004 pandoc_compat: Option<PandocCompat>,
1005
1006 #[serde(default)]
1008 #[serde(rename = "format")]
1009 format_section: Option<StyleConfig>,
1010
1011 #[serde(default)]
1013 style: Option<StyleConfig>,
1014
1015 #[serde(default)]
1017 math_indent: usize,
1018 #[serde(default)]
1019 math_delimiter_style: MathDelimiterStyle,
1020 #[serde(default)]
1021 wrap: Option<WrapMode>,
1022 #[serde(default = "default_blank_lines")]
1023 blank_lines: BlankLines,
1024 #[serde(default)]
1025 tab_stops: TabStopMode,
1026 #[serde(default = "default_tab_width")]
1027 tab_width: usize,
1028 #[serde(default)]
1030 parser: Option<ParserConfig>,
1031
1032 #[serde(default)]
1035 formatters: Option<toml::Value>,
1036
1037 #[serde(default)]
1039 external_max_parallel: Option<usize>,
1040
1041 #[serde(default)]
1042 linters: HashMap<String, String>,
1043 #[serde(default)]
1044 lint: Option<LintConfig>,
1045 #[serde(default)]
1046 cache_dir: Option<String>,
1047 #[serde(default)]
1048 exclude: Option<Vec<String>>,
1049 #[serde(default)]
1050 extend_exclude: Vec<String>,
1051 #[serde(default)]
1052 include: Option<Vec<String>>,
1053 #[serde(default)]
1054 extend_include: Vec<String>,
1055 #[serde(default)]
1056 flavor_overrides: HashMap<String, Flavor>,
1057}
1058
1059fn default_line_width() -> usize {
1060 80
1061}
1062
1063fn default_external_max_parallel() -> usize {
1064 std::thread::available_parallelism()
1066 .map(|n| n.get())
1067 .unwrap_or(1)
1068 .clamp(1, 8)
1069}
1070
1071fn default_blank_lines() -> BlankLines {
1072 BlankLines::Collapse
1073}
1074
1075fn default_tab_width() -> usize {
1076 4
1077}
1078
1079pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
1080 ".Rproj.user/",
1081 ".bzr/",
1082 ".cache/",
1083 ".devevn/",
1084 ".direnv/",
1085 ".git/",
1086 ".hg/",
1087 ".julia/",
1088 ".mypy_cache/",
1089 ".nox/",
1090 ".pytest_cache/",
1091 ".ruff_cache/",
1092 ".svn/",
1093 ".tmp/",
1094 ".tox/",
1095 ".venv/",
1096 ".vscode/",
1097 "_book/",
1098 "_build/",
1099 "_freeze/",
1100 "_site/",
1101 "build/",
1102 "dist/",
1103 "node_modules/",
1104 "renv/",
1105 "target/",
1106 "tests/testthat/_snaps",
1107 "**/LICENSE.md",
1108];
1109
1110pub const DEFAULT_INCLUDE_PATTERNS: &[&str] =
1111 &["*.md", "*.qmd", "*.Rmd", "*.markdown", "*.mdown", "*.mkd"];
1112const MARKDOWN_FAMILY_EXTENSIONS: &[&str] = &["md", "markdown", "mdown", "mkd"];
1113
1114fn resolve_formatter_name(
1142 name: &str,
1143 formatter_definitions: &HashMap<String, FormatterDefinition>,
1144) -> Result<FormatterConfig, String> {
1145 if let Some(definition) = formatter_definitions.get(name) {
1147 if definition.preset.is_some() {
1152 return Err(format!(
1153 "Formatter '{}': 'preset' field not allowed in named definitions. Use [formatters] mapping instead (e.g., `lang = \"{}\"`).",
1154 name, name
1155 ));
1156 }
1157
1158 let preset = get_formatter_preset(name);
1160
1161 match (preset, &definition.cmd) {
1163 (Some(mut base_config), _) => {
1165 if let Some(cmd) = &definition.cmd {
1167 base_config.cmd = cmd.clone();
1168 }
1169 if let Some(args) = &definition.args {
1171 base_config.args = args.clone();
1172 }
1173 if let Some(stdin) = definition.stdin {
1175 base_config.stdin = stdin;
1176 }
1177
1178 apply_arg_modifiers(&mut base_config.args, definition);
1180
1181 Ok(base_config)
1182 }
1183 (None, Some(cmd)) => {
1185 let mut args = definition.args.clone().unwrap_or_default();
1186
1187 apply_arg_modifiers(&mut args, definition);
1189
1190 Ok(FormatterConfig {
1191 cmd: cmd.clone(),
1192 args,
1193 enabled: true,
1194 stdin: definition.stdin.unwrap_or(true),
1195 })
1196 }
1197 (None, None) => Err(format!(
1199 "Formatter '{}': must specify 'cmd' field (not a known preset)",
1200 name
1201 )),
1202 }
1203 } else {
1204 get_formatter_preset(name).ok_or_else(|| {
1206 format!(
1207 "Unknown formatter '{}': not a named definition or built-in preset. \
1208 Define it in [formatters.{}] section or use a known preset.",
1209 name, name
1210 )
1211 })
1212 }
1213}
1214
1215fn apply_arg_modifiers(args: &mut Vec<String>, definition: &FormatterDefinition) {
1220 if let Some(prepend) = &definition.prepend_args {
1222 let mut new_args = prepend.clone();
1223 new_args.append(args);
1224 *args = new_args;
1225 }
1226
1227 if let Some(append) = &definition.append_args {
1229 args.extend_from_slice(append);
1230 }
1231}
1232
1233fn resolve_language_formatters(
1235 lang: &str,
1236 value: &FormatterValue,
1237 formatter_definitions: &HashMap<String, FormatterDefinition>,
1238) -> Result<Vec<FormatterConfig>, String> {
1239 let formatter_names = match value {
1240 FormatterValue::Single(name) => vec![name.as_str()],
1241 FormatterValue::Multiple(names) => names.iter().map(|s| s.as_str()).collect(),
1242 };
1243
1244 formatter_names
1246 .into_iter()
1247 .map(|name| {
1248 if !formatter_definitions.contains_key(name) {
1252 validate_formatter_language_for_preset(lang, name)?;
1253 }
1254 resolve_formatter_name(name, formatter_definitions)
1255 .map_err(|e| format!("Language '{}': {}", lang, e))
1256 })
1257 .collect()
1258}
1259
1260impl RawConfig {
1261 fn finalize(self) -> Config {
1263 let parser_from_section = self.parser.unwrap_or_default();
1264 let parser_pandoc_compat = parser_from_section.pandoc_compat;
1265
1266 let resolved_pandoc_compat = if let Some(pandoc_compat) = self.pandoc_compat {
1267 if parser_pandoc_compat != PandocCompat::default()
1268 && parser_pandoc_compat != pandoc_compat
1269 {
1270 eprintln!(
1271 "Warning: Both top-level 'pandoc-compat' and [parser].pandoc-compat are set. Using top-level 'pandoc-compat'."
1272 );
1273 }
1274 pandoc_compat
1275 } else {
1276 if parser_pandoc_compat != PandocCompat::default() {
1277 eprintln!(
1278 "Warning: [parser].pandoc-compat is deprecated. Please use top-level 'pandoc-compat'."
1279 );
1280 }
1281 parser_pandoc_compat
1282 };
1283
1284 let has_deprecated_fields = self.wrap.is_some()
1286 || self.math_indent != 0
1287 || self.math_delimiter_style != MathDelimiterStyle::default()
1288 || self.blank_lines != default_blank_lines()
1289 || self.tab_stops != TabStopMode::Normalize
1290 || self.tab_width != default_tab_width();
1291
1292 if has_deprecated_fields && self.format_section.is_none() && self.style.is_none() {
1293 eprintln!(
1294 "Warning: top-level style fields (wrap, math-indent, etc.) \
1295 are deprecated. Please move them under [format] section. \
1296 See documentation for the new format."
1297 );
1298 }
1299
1300 let style = if let Some(format_config) = self.format_section {
1302 if self.style.is_some() {
1303 eprintln!(
1304 "Warning: Both [format] and deprecated [style] sections found. \
1305 Using [format] section."
1306 );
1307 }
1308 if has_deprecated_fields {
1309 eprintln!(
1310 "Warning: Both [format] section and top-level style fields found. \
1311 Using [format] section and ignoring top-level fields."
1312 );
1313 }
1314
1315 format_config
1316 } else if let Some(style_config) = self.style {
1317 eprintln!("Warning: [style] section is deprecated. Please use [format] instead.");
1318 if has_deprecated_fields {
1319 eprintln!(
1320 "Warning: Both deprecated [style] section and top-level style fields found. \
1321 Using [style] section and ignoring top-level fields."
1322 );
1323 }
1324 style_config
1325 } else {
1326 StyleConfig {
1328 wrap: self.wrap.or(Some(WrapMode::Reflow)),
1329 blank_lines: self.blank_lines,
1330 math_delimiter_style: self.math_delimiter_style,
1331 math_indent: self.math_indent,
1332 tab_stops: self.tab_stops,
1333 tab_width: self.tab_width,
1334 built_in_greedy_wrap: true,
1335 }
1336 };
1337
1338 Config {
1339 extensions: resolve_extensions_for_flavor(self.extensions.as_ref(), self.flavor),
1340 line_ending: self.line_ending.or(Some(LineEnding::Auto)),
1341 flavor: self.flavor,
1342 line_width: self.line_width,
1343 wrap: style.wrap,
1344 blank_lines: style.blank_lines,
1345 math_delimiter_style: style.math_delimiter_style,
1346 math_indent: style.math_indent,
1347 tab_stops: style.tab_stops,
1348 tab_width: style.tab_width,
1349 formatters: resolve_formatters(self.formatters),
1350 linters: self.linters,
1351 lint: self.lint.unwrap_or_default().normalize(),
1352 cache_dir: self.cache_dir,
1353 external_max_parallel: self
1354 .external_max_parallel
1355 .unwrap_or_else(default_external_max_parallel),
1356 parser: ParserConfig {
1357 pandoc_compat: resolved_pandoc_compat,
1358 },
1359 built_in_greedy_wrap: style.built_in_greedy_wrap,
1360 exclude: self.exclude,
1361 extend_exclude: self.extend_exclude,
1362 include: self.include,
1363 extend_include: self.extend_include,
1364 flavor_overrides: self.flavor_overrides,
1365 }
1366 }
1367}
1368
1369fn parse_flavor_key(s: &str) -> Option<Flavor> {
1370 match s.replace('_', "-").to_lowercase().as_str() {
1371 "pandoc" => Some(Flavor::Pandoc),
1372 "quarto" => Some(Flavor::Quarto),
1373 "rmarkdown" | "r-markdown" => Some(Flavor::RMarkdown),
1374 "gfm" => Some(Flavor::Gfm),
1375 "common-mark" | "commonmark" => Some(Flavor::CommonMark),
1376 "multimarkdown" | "multi-markdown" => Some(Flavor::MultiMarkdown),
1377 _ => None,
1378 }
1379}
1380
1381fn resolve_extensions_for_flavor(
1382 extensions_value: Option<&toml::Value>,
1383 flavor: Flavor,
1384) -> Extensions {
1385 let Some(value) = extensions_value else {
1386 return Extensions::for_flavor(flavor);
1387 };
1388
1389 let Some(table) = value.as_table() else {
1390 eprintln!("Warning: [extensions] must be a table; using flavor defaults.");
1391 return Extensions::for_flavor(flavor);
1392 };
1393
1394 let mut global_overrides = HashMap::new();
1395 let mut flavor_overrides = HashMap::new();
1396
1397 for (key, val) in table {
1398 if let Some(enabled) = val.as_bool() {
1399 global_overrides.insert(key.clone(), enabled);
1400 continue;
1401 }
1402
1403 let Some(flavor_table) = val.as_table() else {
1404 eprintln!(
1405 "Warning: [extensions] entry '{}' must be a boolean or table; ignoring.",
1406 key
1407 );
1408 continue;
1409 };
1410
1411 let Some(target_flavor) = parse_flavor_key(key) else {
1412 eprintln!(
1413 "Warning: [extensions.{}] is not a known flavor table; ignoring.",
1414 key
1415 );
1416 continue;
1417 };
1418
1419 if target_flavor != flavor {
1420 continue;
1421 }
1422
1423 for (sub_key, sub_val) in flavor_table {
1424 let Some(enabled) = sub_val.as_bool() else {
1425 eprintln!(
1426 "Warning: [extensions.{}] entry '{}' must be true or false; ignoring.",
1427 key, sub_key
1428 );
1429 continue;
1430 };
1431 flavor_overrides.insert(sub_key.clone(), enabled);
1432 }
1433 }
1434
1435 let base = Extensions::merge_with_flavor(global_overrides, flavor);
1436 Extensions::merge_overrides(base, flavor_overrides)
1437}
1438
1439fn resolve_formatters(
1442 raw_formatters: Option<toml::Value>,
1443) -> HashMap<String, Vec<FormatterConfig>> {
1444 let Some(value) = raw_formatters else {
1445 return HashMap::new();
1446 };
1447
1448 let toml::Value::Table(table) = value else {
1450 eprintln!("Warning: Invalid formatters configuration - expected table");
1451 return HashMap::new();
1452 };
1453
1454 let has_string_or_array = table
1459 .values()
1460 .any(|v| matches!(v, toml::Value::String(_) | toml::Value::Array(_)));
1461
1462 if has_string_or_array {
1463 resolve_new_format_formatters(table)
1465 } else {
1466 resolve_old_format_formatters(table)
1468 }
1469}
1470
1471fn resolve_new_format_formatters(
1474 table: toml::map::Map<String, toml::Value>,
1475) -> HashMap<String, Vec<FormatterConfig>> {
1476 let mut mappings = HashMap::new();
1477 let mut definitions = HashMap::new();
1478
1479 for (key, value) in table {
1481 match &value {
1482 toml::Value::String(_) | toml::Value::Array(_) => {
1483 let formatter_value: Result<FormatterValue, _> = value.try_into();
1485 match formatter_value {
1486 Ok(fv) => {
1487 mappings.insert(key, fv);
1488 }
1489 Err(e) => {
1490 eprintln!("Error parsing formatter value for '{}': {}", key, e);
1491 }
1492 }
1493 }
1494 toml::Value::Table(_) => {
1495 let definition: Result<FormatterDefinition, _> = value.try_into();
1497 match definition {
1498 Ok(def) => {
1499 definitions.insert(key, def);
1500 }
1501 Err(e) => {
1502 eprintln!("Error parsing formatter definition '{}': {}", key, e);
1503 }
1504 }
1505 }
1506 _ => {
1507 eprintln!(
1508 "Warning: Invalid formatter entry '{}' - must be string, array, or table",
1509 key
1510 );
1511 }
1512 }
1513 }
1514
1515 let mut resolved = HashMap::new();
1517 for (lang, value) in mappings {
1518 match resolve_language_formatters(&lang, &value, &definitions) {
1519 Ok(configs) if !configs.is_empty() => {
1520 resolved.insert(lang, configs);
1521 }
1522 Ok(_) => {} Err(e) => {
1524 eprintln!("Error resolving formatters for language '{}': {}", lang, e);
1525 eprintln!("Skipping formatter for '{}'", lang);
1526 }
1527 }
1528 }
1529
1530 resolved
1531}
1532
1533fn resolve_old_format_formatters(
1535 table: toml::map::Map<String, toml::Value>,
1536) -> HashMap<String, Vec<FormatterConfig>> {
1537 eprintln!(
1538 "Warning: Old formatter configuration format detected. \
1539 Please migrate to the new format with [formatters] section. \
1540 See documentation for the new format."
1541 );
1542
1543 let mut resolved = HashMap::new();
1544 for (lang, value) in table {
1545 let definition: Result<FormatterDefinition, _> = value.try_into();
1546 match definition {
1547 Ok(def) => {
1548 if def.enabled == Some(false) {
1551 continue;
1552 }
1553
1554 match resolve_old_format_definition(&lang, &def) {
1555 Ok(config) => {
1556 resolved.insert(lang, vec![config]);
1557 }
1558 Err(e) => {
1559 eprintln!("Error in old formatter config for '{}': {}", lang, e);
1560 eprintln!("Skipping formatter for '{}'", lang);
1561 }
1562 }
1563 }
1564 Err(e) => {
1565 eprintln!("Error parsing old formatter config for '{}': {}", lang, e);
1566 }
1567 }
1568 }
1569
1570 resolved
1571}
1572
1573fn resolve_old_format_definition(
1575 _lang: &str,
1576 definition: &FormatterDefinition,
1577) -> Result<FormatterConfig, String> {
1578 if definition.preset.is_some() && definition.cmd.is_some() {
1580 return Err("'preset' and 'cmd' are mutually exclusive".to_string());
1581 }
1582
1583 if let Some(preset_name) = &definition.preset {
1584 let preset = get_formatter_preset(preset_name).ok_or_else(|| {
1586 let available = formatter_preset_names().join(", ");
1587 format!(
1588 "Unknown formatter preset '{}'. Available presets: {}",
1589 preset_name, available
1590 )
1591 })?;
1592
1593 let mut args = definition.args.clone().unwrap_or(preset.args);
1594
1595 apply_arg_modifiers(&mut args, definition);
1597
1598 Ok(FormatterConfig {
1599 cmd: preset.cmd,
1600 args,
1601 enabled: true, stdin: preset.stdin,
1603 })
1604 } else if let Some(cmd) = &definition.cmd {
1605 let mut args = definition.args.clone().unwrap_or_default();
1607
1608 apply_arg_modifiers(&mut args, definition);
1610
1611 Ok(FormatterConfig {
1612 cmd: cmd.clone(),
1613 args,
1614 enabled: true,
1615 stdin: definition.stdin.unwrap_or(true),
1616 })
1617 } else {
1618 Err("must specify either 'preset' or 'cmd'".to_string())
1619 }
1620}
1621
1622#[derive(Debug, Clone)]
1623pub struct Config {
1624 pub flavor: Flavor,
1625 pub extensions: Extensions,
1626 pub line_ending: Option<LineEnding>,
1627 pub line_width: usize,
1628 pub math_indent: usize,
1629 pub math_delimiter_style: MathDelimiterStyle,
1630 pub tab_stops: TabStopMode,
1631 pub tab_width: usize,
1632 pub wrap: Option<WrapMode>,
1633 pub blank_lines: BlankLines,
1634 pub formatters: HashMap<String, Vec<FormatterConfig>>,
1636 pub linters: HashMap<String, String>,
1637 pub external_max_parallel: usize,
1639 pub parser: ParserConfig,
1641 pub lint: LintConfig,
1643 pub cache_dir: Option<String>,
1645 pub built_in_greedy_wrap: bool,
1646 pub exclude: Option<Vec<String>>,
1647 pub extend_exclude: Vec<String>,
1648 pub include: Option<Vec<String>>,
1649 pub extend_include: Vec<String>,
1650 pub flavor_overrides: HashMap<String, Flavor>,
1651}
1652
1653impl<'de> Deserialize<'de> for Config {
1654 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1655 where
1656 D: Deserializer<'de>,
1657 {
1658 RawConfig::deserialize(deserializer).map(|raw| raw.finalize())
1659 }
1660}
1661
1662impl Default for Config {
1663 fn default() -> Self {
1664 let flavor = Flavor::default();
1665 Self {
1666 flavor,
1667 extensions: Extensions::for_flavor(flavor),
1668 line_ending: Some(LineEnding::Auto),
1669 line_width: 80,
1670 math_indent: 0,
1671 math_delimiter_style: MathDelimiterStyle::default(),
1672 tab_stops: TabStopMode::Normalize,
1673 tab_width: 4,
1674 wrap: Some(WrapMode::Reflow),
1675 blank_lines: BlankLines::Collapse,
1676 formatters: HashMap::new(), linters: HashMap::new(), external_max_parallel: default_external_max_parallel(),
1679 parser: ParserConfig::default(),
1680 lint: LintConfig::default(),
1681 cache_dir: None,
1682 built_in_greedy_wrap: true,
1683 exclude: None,
1684 extend_exclude: Vec::new(),
1685 include: None,
1686 extend_include: Vec::new(),
1687 flavor_overrides: HashMap::new(),
1688 }
1689 }
1690}
1691
1692#[derive(Default, Clone)]
1693pub struct ConfigBuilder {
1694 config: Config,
1695}
1696
1697impl ConfigBuilder {
1698 pub fn math_indent(mut self, indent: usize) -> Self {
1699 self.config.math_indent = indent;
1700 self
1701 }
1702
1703 pub fn tab_stops(mut self, mode: TabStopMode) -> Self {
1704 self.config.tab_stops = mode;
1705 self
1706 }
1707
1708 pub fn tab_width(mut self, width: usize) -> Self {
1709 self.config.tab_width = width;
1710 self
1711 }
1712
1713 pub fn line_width(mut self, width: usize) -> Self {
1714 self.config.line_width = width;
1715 self
1716 }
1717
1718 pub fn line_ending(mut self, ending: LineEnding) -> Self {
1719 self.config.line_ending = Some(ending);
1720 self
1721 }
1722
1723 pub fn blank_lines(mut self, mode: BlankLines) -> Self {
1724 self.config.blank_lines = mode;
1725 self
1726 }
1727
1728 pub fn build(self) -> Config {
1729 self.config
1730 }
1731}
1732
1733#[derive(Debug, Clone, Deserialize, PartialEq)]
1734#[serde(rename_all = "kebab-case")]
1735pub enum WrapMode {
1736 Preserve,
1737 Reflow,
1738 Sentence,
1739}
1740
1741#[derive(Debug, Clone, Deserialize, PartialEq)]
1742#[serde(rename_all = "kebab-case")]
1743pub enum LineEnding {
1744 Auto,
1745 Lf,
1746 Crlf,
1747}
1748
1749#[derive(Debug, Clone, Deserialize, PartialEq)]
1750#[serde(rename_all = "kebab-case")]
1751pub enum BlankLines {
1752 Preserve,
1754 Collapse,
1756}
1757
1758const CANDIDATE_NAMES: &[&str] = &[".panache.toml", "panache.toml"];
1759
1760fn check_deprecated_extension_names(s: &str, path: &Path) {
1762 let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1764 return; };
1766
1767 let Some(extensions_table) = toml_value
1768 .as_table()
1769 .and_then(|t| t.get("extensions"))
1770 .and_then(|v| v.as_table())
1771 else {
1772 return; };
1774
1775 let deprecated_names: Vec<&str> = extensions_table
1777 .keys()
1778 .filter(|k| k.contains('_'))
1779 .map(|k| k.as_str())
1780 .collect();
1781
1782 if !deprecated_names.is_empty() {
1783 eprintln!(
1784 "Warning: Deprecated snake_case extension names found in {}:",
1785 path.display()
1786 );
1787 eprintln!(" The following extensions use deprecated snake_case naming:");
1788 for name in &deprecated_names {
1789 let kebab = name.replace('_', "-");
1790 eprintln!(" {} -> {} (use kebab-case)", name, kebab);
1791 }
1792 eprintln!(" Snake_case extension names are deprecated and will be removed in v1.0.0.");
1793 eprintln!(
1794 " Please update your config to use kebab-case (e.g., quarto-crossrefs instead of quarto_crossrefs)."
1795 );
1796 }
1797}
1798
1799fn check_deprecated_formatter_names(s: &str, path: &Path) {
1801 let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1803 return;
1804 };
1805
1806 let Some(formatters_table) = toml_value
1807 .as_table()
1808 .and_then(|t| t.get("formatters"))
1809 .and_then(|v| v.as_table())
1810 else {
1811 return; };
1813
1814 let mut found_deprecated = false;
1816 for (formatter_name, formatter_value) in formatters_table {
1817 if let Some(formatter_def) = formatter_value.as_table() {
1818 let deprecated_fields: Vec<&str> = formatter_def
1819 .keys()
1820 .filter(|k| matches!(k.as_str(), "prepend_args" | "append_args"))
1821 .map(|k| k.as_str())
1822 .collect();
1823
1824 if !deprecated_fields.is_empty() {
1825 if !found_deprecated {
1826 eprintln!(
1827 "Warning: Deprecated snake_case formatter field names found in {}:",
1828 path.display()
1829 );
1830 found_deprecated = true;
1831 }
1832 eprintln!(" In [formatters.{}]:", formatter_name);
1833 for field in deprecated_fields {
1834 let kebab = field.replace('_', "-");
1835 eprintln!(" {} -> {}", field, kebab);
1836 }
1837 }
1838 }
1839 }
1840
1841 if found_deprecated {
1842 eprintln!(
1843 " Snake_case formatter field names are deprecated and will be removed in v1.0.0."
1844 );
1845 eprintln!(
1846 " Please update your config to use kebab-case (e.g., prepend-args instead of prepend_args)."
1847 );
1848 }
1849}
1850
1851fn check_deprecated_code_block_style_options(s: &str, path: &Path) {
1853 let Ok(toml_value) = toml::from_str::<toml::Value>(s) else {
1854 return;
1855 };
1856 let Some(root) = toml_value.as_table() else {
1857 return;
1858 };
1859
1860 let top_level = root.contains_key("code-blocks");
1861 let format_nested = root
1862 .get("format")
1863 .and_then(|v| v.as_table())
1864 .is_some_and(|format| format.contains_key("code-blocks"));
1865 let style_nested = root
1866 .get("style")
1867 .and_then(|v| v.as_table())
1868 .is_some_and(|style| style.contains_key("code-blocks"));
1869
1870 if top_level || format_nested || style_nested {
1871 eprintln!(
1872 "Warning: Deprecated code block style options found in {}:",
1873 path.display()
1874 );
1875 if format_nested {
1876 eprintln!(" - [format.code-blocks]");
1877 }
1878 if top_level {
1879 eprintln!(" - [code-blocks]");
1880 }
1881 if style_nested {
1882 eprintln!(" - [style.code-blocks]");
1883 }
1884 eprintln!(" These options are now no-ops and will be removed in a future release.");
1885 }
1886}
1887
1888fn parse_config_str(s: &str, path: &Path) -> io::Result<Config> {
1889 check_deprecated_extension_names(s, path);
1891 check_deprecated_formatter_names(s, path);
1892 check_deprecated_code_block_style_options(s, path);
1893
1894 let config: Config = toml::from_str(s).map_err(|e| {
1895 io::Error::new(
1896 io::ErrorKind::InvalidData,
1897 format!("invalid config {}: {e}", path.display()),
1898 )
1899 })?;
1900
1901 Ok(config)
1902}
1903
1904fn read_config(path: &Path) -> io::Result<Config> {
1905 log::debug!("Reading config from: {}", path.display());
1906 let s = fs::read_to_string(path)?;
1907 let config = parse_config_str(&s, path)?;
1908 log::debug!("Loaded config from: {}", path.display());
1909 Ok(config)
1910}
1911
1912fn find_in_tree(start_dir: &Path) -> Option<PathBuf> {
1913 for dir in start_dir.ancestors() {
1914 for name in CANDIDATE_NAMES {
1915 let p = dir.join(name);
1916 if p.is_file() {
1917 return Some(p);
1918 }
1919 }
1920 }
1921 None
1922}
1923
1924fn xdg_config_path() -> Option<PathBuf> {
1925 if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
1926 let p = Path::new(&xdg).join("panache").join("config.toml");
1927 if p.is_file() {
1928 return Some(p);
1929 }
1930 }
1931 if let Ok(home) = env::var("HOME") {
1932 let p = Path::new(&home)
1933 .join(".config")
1934 .join("panache")
1935 .join("config.toml");
1936 if p.is_file() {
1937 return Some(p);
1938 }
1939 }
1940 None
1941}
1942
1943pub fn load(
1959 explicit: Option<&Path>,
1960 start_dir: &Path,
1961 input_file: Option<&Path>,
1962) -> io::Result<(Config, Option<PathBuf>)> {
1963 let (mut cfg, cfg_path) = if let Some(path) = explicit {
1964 let cfg = read_config(path)?;
1965 (cfg, Some(path.to_path_buf()))
1966 } else if let Some(p) = find_in_tree(start_dir)
1967 && let Ok(cfg) = read_config(&p)
1968 {
1969 (cfg, Some(p))
1970 } else if let Some(p) = xdg_config_path()
1971 && let Ok(cfg) = read_config(&p)
1972 {
1973 (cfg, Some(p))
1974 } else {
1975 log::debug!("No config file found, using defaults");
1976 (Config::default(), None)
1977 };
1978
1979 if let Some(flavor) = detect_flavor(input_file, cfg_path.as_deref(), &cfg) {
1980 cfg.flavor = flavor;
1981 cfg.extensions = if let Some(path) = cfg_path.as_deref() {
1982 fs::read_to_string(path)
1983 .ok()
1984 .and_then(|s| toml::from_str::<toml::Value>(&s).ok())
1985 .map(|root| resolve_extensions_for_flavor(root.get("extensions"), flavor))
1986 .unwrap_or_else(|| Extensions::for_flavor(flavor))
1987 } else {
1988 Extensions::for_flavor(flavor)
1989 };
1990 }
1991
1992 Ok((cfg, cfg_path))
1993}
1994
1995fn detect_flavor(
1996 input_file: Option<&Path>,
1997 cfg_path: Option<&Path>,
1998 cfg: &Config,
1999) -> Option<Flavor> {
2000 let input_path = input_file?;
2001 let ext = input_path.extension().and_then(|e| e.to_str())?;
2002 let ext_lower = ext.to_lowercase();
2003
2004 match ext_lower.as_str() {
2005 "qmd" => {
2006 log::debug!("Using Quarto flavor for .qmd file");
2007 Some(Flavor::Quarto)
2008 }
2009 "rmd" => {
2010 log::debug!("Using RMarkdown flavor for .Rmd file");
2011 Some(Flavor::RMarkdown)
2012 }
2013 _ if MARKDOWN_FAMILY_EXTENSIONS.contains(&ext_lower.as_str()) => {
2014 let base_dir = cfg_path.and_then(Path::parent);
2015 let override_flavor =
2016 detect_flavor_override(input_path, base_dir, &cfg.flavor_overrides);
2017 let final_flavor = override_flavor.unwrap_or(cfg.flavor);
2018 if let Some(flavor) = override_flavor {
2019 log::debug!(
2020 "Using {:?} flavor for {} (matched flavor-overrides)",
2021 flavor,
2022 input_path.display()
2023 );
2024 } else {
2025 log::debug!(
2026 "Using {:?} flavor for {} (from config)",
2027 final_flavor,
2028 input_path.display()
2029 );
2030 }
2031 Some(final_flavor)
2032 }
2033 _ => None,
2034 }
2035}
2036
2037fn detect_flavor_override(
2038 input_path: &Path,
2039 base_dir: Option<&Path>,
2040 overrides: &HashMap<String, Flavor>,
2041) -> Option<Flavor> {
2042 if overrides.is_empty() {
2043 return None;
2044 }
2045
2046 let full_path = normalize_path_for_matching(input_path);
2047 let rel_path = base_dir
2048 .and_then(|base| input_path.strip_prefix(base).ok())
2049 .map(normalize_path_for_matching);
2050 let file_name = input_path
2051 .file_name()
2052 .and_then(|name| name.to_str())
2053 .map(|name| name.to_string());
2054
2055 let mut best: Option<((usize, usize, usize), Flavor)> = None;
2056 for (pattern, flavor) in overrides {
2057 let matched = glob_matches_path(pattern, &full_path)
2058 || rel_path
2059 .as_deref()
2060 .is_some_and(|relative| glob_matches_path(pattern, relative))
2061 || file_name
2062 .as_deref()
2063 .is_some_and(|name| glob_matches_path(pattern, name));
2064 if !matched {
2065 continue;
2066 }
2067
2068 let score = pattern_specificity(pattern);
2069 if best.is_none_or(|(best_score, _)| score > best_score) {
2070 best = Some((score, *flavor));
2071 }
2072 }
2073
2074 best.map(|(_, flavor)| flavor)
2075}
2076
2077fn normalize_path_for_matching(path: &Path) -> String {
2078 path.to_string_lossy().replace('\\', "/")
2079}
2080
2081fn pattern_specificity(pattern: &str) -> (usize, usize, usize) {
2082 let literal_chars = pattern.chars().filter(|c| *c != '*' && *c != '?').count();
2083 let segment_count = pattern
2084 .split('/')
2085 .filter(|segment| !segment.is_empty())
2086 .count();
2087 let wildcard_count = pattern.chars().filter(|c| *c == '*' || *c == '?').count();
2088 (literal_chars, segment_count, usize::MAX - wildcard_count)
2089}
2090
2091fn glob_matches_path(pattern: &str, path: &str) -> bool {
2092 let normalized_pattern = pattern.replace('\\', "/");
2093 GlobBuilder::new(&normalized_pattern)
2094 .literal_separator(true)
2095 .build()
2096 .map(|glob| glob.compile_matcher().is_match(path))
2097 .unwrap_or(false)
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102 use super::*;
2103
2104 #[test]
2105 fn missing_fields_uses_defaults() {
2106 let toml_str = r#"
2107 wrap = "reflow"
2108 "#;
2109 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2110 assert_eq!(cfg.line_width, 80);
2111 assert!(cfg.formatters.is_empty());
2113 assert!(cfg.cache_dir.is_none());
2114 }
2115
2116 #[test]
2117 fn formatter_config_basic() {
2118 let toml_str = r#"
2119 [formatters.python]
2120 cmd = "black"
2121 args = ["-"]
2122 "#;
2123 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2124
2125 let python_fmt = &cfg.formatters.get("python").unwrap()[0];
2126 assert_eq!(python_fmt.cmd, "black");
2127 assert_eq!(python_fmt.args, vec!["-"]);
2128 assert!(python_fmt.enabled);
2129 }
2130
2131 #[test]
2132 fn formatter_config_multiple_languages() {
2133 let toml_str = r#"
2134 [formatters.r]
2135 cmd = "air"
2136 args = ["--preset=tidyverse"]
2137
2138 [formatters.python]
2139 cmd = "black"
2140 args = ["-", "--line-length=88"]
2141
2142 [formatters.rust]
2143 cmd = "rustfmt"
2144 enabled = false
2145 "#;
2146 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2147
2148 assert_eq!(cfg.formatters.len(), 2);
2150
2151 let r_fmt = &cfg.formatters.get("r").unwrap()[0];
2152 assert_eq!(r_fmt.cmd, "air");
2153 assert_eq!(r_fmt.args, vec!["--preset=tidyverse"]);
2154 assert!(r_fmt.enabled);
2155
2156 let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2157 assert_eq!(py_fmt.cmd, "black");
2158 assert_eq!(py_fmt.args.len(), 2);
2159
2160 assert!(!cfg.formatters.contains_key("rust"));
2162 }
2163
2164 #[test]
2165 fn cache_dir_parsing() {
2166 let toml_str = r#"
2167 cache-dir = ".panache/local-cache"
2168 "#;
2169 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2170 assert_eq!(cfg.cache_dir.as_deref(), Some(".panache/local-cache"));
2171 }
2172
2173 #[test]
2174 fn formatter_config_no_args() {
2175 let toml_str = r#"
2176 [formatters.rustfmt]
2177 cmd = "rustfmt"
2178 "#;
2179 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2180
2181 let fmt = &cfg.formatters.get("rustfmt").unwrap()[0];
2182 assert_eq!(fmt.cmd, "rustfmt");
2183 assert!(fmt.args.is_empty());
2184 assert!(fmt.enabled);
2185 }
2186
2187 #[test]
2188 fn formatter_empty_cmd_is_valid() {
2189 let toml_str = r#"
2192 [formatters.test]
2193 cmd = ""
2194 "#;
2195 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2196 let fmt = &cfg.formatters.get("test").unwrap()[0];
2197 assert_eq!(fmt.cmd, "");
2198 }
2199
2200 #[test]
2201 fn preset_resolution_air() {
2202 let toml_str = r#"
2203 [formatters.r]
2204 preset = "air"
2205 "#;
2206 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2207 let r_fmt = &cfg.formatters.get("r").unwrap()[0];
2208 assert_eq!(r_fmt.cmd, "air");
2209 assert_eq!(r_fmt.args, vec!["format", "{}"]);
2210 assert!(!r_fmt.stdin);
2211 assert!(r_fmt.enabled);
2212 }
2213
2214 #[test]
2215 fn preset_resolution_ruff() {
2216 let toml_str = r#"
2217 [formatters.python]
2218 preset = "ruff"
2219 "#;
2220 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2221 let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2222 assert_eq!(py_fmt.cmd, "ruff");
2223 assert_eq!(
2224 py_fmt.args,
2225 vec!["format", "--stdin-filename", "stdin.py", "-"]
2226 );
2227 assert!(py_fmt.stdin);
2228 assert!(py_fmt.enabled);
2229 }
2230
2231 #[test]
2232 fn preset_resolution_black() {
2233 let toml_str = r#"
2234 [formatters.python]
2235 preset = "black"
2236 "#;
2237 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2238 let py_fmt = &cfg.formatters.get("python").unwrap()[0];
2239 assert_eq!(py_fmt.cmd, "black");
2240 assert_eq!(py_fmt.args, vec!["-"]);
2241 assert!(py_fmt.stdin);
2242 }
2243
2244 #[test]
2245 fn preset_resolution_sqlfmt() {
2246 let toml_str = r#"
2247 [formatters.sql]
2248 preset = "sqlfmt"
2249 "#;
2250 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2251 let fmt = &cfg.formatters.get("sql").unwrap()[0];
2252 assert_eq!(fmt.cmd, "sqlfmt");
2253 assert_eq!(fmt.args, vec!["-"]);
2254 assert!(fmt.stdin);
2255 }
2256
2257 #[test]
2258 fn preset_resolution_alejandra() {
2259 let toml_str = r#"
2260 [formatters.nix]
2261 preset = "alejandra"
2262 "#;
2263 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2264 let fmt = &cfg.formatters.get("nix").unwrap()[0];
2265 assert_eq!(fmt.cmd, "alejandra");
2266 assert!(fmt.args.is_empty());
2267 assert!(fmt.stdin);
2268 }
2269
2270 #[test]
2271 fn preset_resolution_terraform_fmt() {
2272 let toml_str = r#"
2273 [formatters.hcl]
2274 preset = "terraform-fmt"
2275 "#;
2276 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2277 let fmt = &cfg.formatters.get("hcl").unwrap()[0];
2278 assert_eq!(fmt.cmd, "terraform");
2279 assert_eq!(fmt.args, vec!["fmt", "-no-color", "-"]);
2280 assert!(fmt.stdin);
2281 }
2282
2283 #[test]
2284 fn preset_resolution_yamlfix() {
2285 let toml_str = r#"
2286 [formatters.yaml]
2287 preset = "yamlfix"
2288 "#;
2289 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2290 let fmt = &cfg.formatters.get("yaml").unwrap()[0];
2291 assert_eq!(fmt.cmd, "yamlfix");
2292 assert_eq!(fmt.args, vec!["-"]);
2293 assert!(fmt.stdin);
2294 }
2295
2296 #[test]
2297 fn preset_resolution_gofmt() {
2298 let toml_str = r#"
2299 [formatters.go]
2300 preset = "gofmt"
2301 "#;
2302 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2303 let fmt = &cfg.formatters.get("go").unwrap()[0];
2304 assert_eq!(fmt.cmd, "gofmt");
2305 assert!(fmt.args.is_empty());
2306 assert!(fmt.stdin);
2307 }
2308
2309 #[test]
2310 fn preset_resolution_gofumpt() {
2311 let toml_str = r#"
2312 [formatters.go]
2313 preset = "gofumpt"
2314 "#;
2315 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2316 let fmt = &cfg.formatters.get("go").unwrap()[0];
2317 assert_eq!(fmt.cmd, "gofumpt");
2318 assert!(fmt.args.is_empty());
2319 assert!(fmt.stdin);
2320 }
2321
2322 #[test]
2323 fn preset_resolution_nixfmt() {
2324 let toml_str = r#"
2325 [formatters.nix]
2326 preset = "nixfmt"
2327 "#;
2328 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2329 let fmt = &cfg.formatters.get("nix").unwrap()[0];
2330 assert_eq!(fmt.cmd, "nixfmt");
2331 assert!(fmt.args.is_empty());
2332 assert!(fmt.stdin);
2333 }
2334
2335 #[test]
2336 fn preset_resolution_gleam() {
2337 let toml_str = r#"
2338 [formatters.gleam]
2339 preset = "gleam"
2340 "#;
2341 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2342 let fmt = &cfg.formatters.get("gleam").unwrap()[0];
2343 assert_eq!(fmt.cmd, "gleam");
2344 assert_eq!(fmt.args, vec!["format", "--stdin"]);
2345 assert!(fmt.stdin);
2346 }
2347
2348 #[test]
2349 fn preset_resolution_yq() {
2350 let toml_str = r#"
2351 [formatters.yaml]
2352 preset = "yq"
2353 "#;
2354 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2355 let fmt = &cfg.formatters.get("yaml").unwrap()[0];
2356 assert_eq!(fmt.cmd, "yq");
2357 assert_eq!(fmt.args, vec!["-P", "-"]);
2358 assert!(fmt.stdin);
2359 }
2360
2361 #[test]
2362 fn preset_resolution_asmfmt() {
2363 let toml_str = r#"
2364 [formatters.asm]
2365 preset = "asmfmt"
2366 "#;
2367 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2368 let fmt = &cfg.formatters.get("asm").unwrap()[0];
2369 assert_eq!(fmt.cmd, "asmfmt");
2370 assert!(fmt.args.is_empty());
2371 assert!(fmt.stdin);
2372 }
2373
2374 #[test]
2375 fn preset_resolution_astyle() {
2376 let toml_str = r#"
2377 [formatters.cpp]
2378 preset = "astyle"
2379 "#;
2380 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2381 let fmt = &cfg.formatters.get("cpp").unwrap()[0];
2382 assert_eq!(fmt.cmd, "astyle");
2383 assert_eq!(fmt.args, vec!["--quiet"]);
2384 assert!(fmt.stdin);
2385 }
2386
2387 #[test]
2388 fn preset_resolution_autocorrect() {
2389 let toml_str = r#"
2390 [formatters.text]
2391 preset = "autocorrect"
2392 "#;
2393 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2394 let fmt = &cfg.formatters.get("text").unwrap()[0];
2395 assert_eq!(fmt.cmd, "autocorrect");
2396 assert_eq!(fmt.args, vec!["--stdin"]);
2397 assert!(fmt.stdin);
2398 }
2399
2400 #[test]
2401 fn preset_resolution_cmake_format() {
2402 let toml_str = r#"
2403 [formatters.cmake]
2404 preset = "cmake-format"
2405 "#;
2406 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2407 let fmt = &cfg.formatters.get("cmake").unwrap()[0];
2408 assert_eq!(fmt.cmd, "cmake-format");
2409 assert_eq!(fmt.args, vec!["-"]);
2410 assert!(fmt.stdin);
2411 }
2412
2413 #[test]
2414 fn preset_resolution_cue_fmt() {
2415 let toml_str = r#"
2416 [formatters.cue]
2417 preset = "cue-fmt"
2418 "#;
2419 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2420 let fmt = &cfg.formatters.get("cue").unwrap()[0];
2421 assert_eq!(fmt.cmd, "cue");
2422 assert_eq!(fmt.args, vec!["fmt", "-"]);
2423 assert!(fmt.stdin);
2424 }
2425
2426 #[test]
2427 fn preset_resolution_jsonnetfmt() {
2428 let toml_str = r#"
2429 [formatters.jsonnet]
2430 preset = "jsonnetfmt"
2431 "#;
2432 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2433 let fmt = &cfg.formatters.get("jsonnet").unwrap()[0];
2434 assert_eq!(fmt.cmd, "jsonnetfmt");
2435 assert_eq!(fmt.args, vec!["-"]);
2436 assert!(fmt.stdin);
2437 }
2438
2439 #[test]
2440 fn preset_resolution_dfmt() {
2441 let toml_str = r#"
2442 [formatters.d]
2443 preset = "dfmt"
2444 "#;
2445 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2446 let fmt = &cfg.formatters.get("d").unwrap()[0];
2447 assert_eq!(fmt.cmd, "dfmt");
2448 assert!(fmt.args.is_empty());
2449 assert!(fmt.stdin);
2450 }
2451
2452 #[test]
2453 fn preset_resolution_efmt() {
2454 let toml_str = r#"
2455 [formatters.erl]
2456 preset = "efmt"
2457 "#;
2458 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2459 let fmt = &cfg.formatters.get("erl").unwrap()[0];
2460 assert_eq!(fmt.cmd, "efmt");
2461 assert_eq!(fmt.args, vec!["-"]);
2462 assert!(fmt.stdin);
2463 }
2464
2465 #[test]
2466 fn preset_resolution_nginxfmt() {
2467 let toml_str = r#"
2468 [formatters.nginx]
2469 preset = "nginxfmt"
2470 "#;
2471 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2472 let fmt = &cfg.formatters.get("nginx").unwrap()[0];
2473 assert_eq!(fmt.cmd, "nginxfmt");
2474 assert_eq!(fmt.args, vec!["-"]);
2475 assert!(fmt.stdin);
2476 }
2477
2478 #[test]
2479 fn preset_resolution_tclfmt() {
2480 let toml_str = r#"
2481 [formatters.tcl]
2482 preset = "tclfmt"
2483 "#;
2484 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2485 let fmt = &cfg.formatters.get("tcl").unwrap()[0];
2486 assert_eq!(fmt.cmd, "tclfmt");
2487 assert_eq!(fmt.args, vec!["-"]);
2488 assert!(fmt.stdin);
2489 }
2490
2491 #[test]
2492 fn preset_resolution_tex_fmt() {
2493 let toml_str = r#"
2494 [formatters.tex]
2495 preset = "tex-fmt"
2496 "#;
2497 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2498 let fmt = &cfg.formatters.get("tex").unwrap()[0];
2499 assert_eq!(fmt.cmd, "tex-fmt");
2500 assert_eq!(fmt.args, vec!["-s"]);
2501 assert!(fmt.stdin);
2502 }
2503
2504 #[test]
2505 fn preset_resolution_typstyle() {
2506 let toml_str = r#"
2507 [formatters.typst]
2508 preset = "typstyle"
2509 "#;
2510 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2511 let fmt = &cfg.formatters.get("typst").unwrap()[0];
2512 assert_eq!(fmt.cmd, "typstyle");
2513 assert!(fmt.args.is_empty());
2514 assert!(fmt.stdin);
2515 }
2516
2517 #[test]
2518 fn preset_resolution_gdformat() {
2519 let toml_str = r#"
2520 [formatters.gdscript]
2521 preset = "gdformat"
2522 "#;
2523 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2524 let fmt = &cfg.formatters.get("gdscript").unwrap()[0];
2525 assert_eq!(fmt.cmd, "gdformat");
2526 assert_eq!(fmt.args, vec!["-"]);
2527 assert!(fmt.stdin);
2528 }
2529
2530 #[test]
2531 fn preset_resolution_hurlfmt() {
2532 let toml_str = r#"
2533 [formatters.hurl]
2534 preset = "hurlfmt"
2535 "#;
2536 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2537 let fmt = &cfg.formatters.get("hurl").unwrap()[0];
2538 assert_eq!(fmt.cmd, "hurlfmt");
2539 assert!(fmt.args.is_empty());
2540 assert!(fmt.stdin);
2541 }
2542
2543 #[test]
2544 fn preset_resolution_ktfmt() {
2545 let toml_str = r#"
2546 [formatters.kotlin]
2547 preset = "ktfmt"
2548 "#;
2549 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2550 let fmt = &cfg.formatters.get("kotlin").unwrap()[0];
2551 assert_eq!(fmt.cmd, "ktfmt");
2552 assert_eq!(fmt.args, vec!["-"]);
2553 assert!(fmt.stdin);
2554 }
2555
2556 #[test]
2557 fn preset_resolution_leptosfmt() {
2558 let toml_str = r#"
2559 [formatters.rust]
2560 preset = "leptosfmt"
2561 "#;
2562 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2563 let fmt = &cfg.formatters.get("rust").unwrap()[0];
2564 assert_eq!(fmt.cmd, "leptosfmt");
2565 assert_eq!(fmt.args, vec!["--stdin"]);
2566 assert!(fmt.stdin);
2567 }
2568
2569 #[test]
2570 fn preset_resolution_pycln() {
2571 let toml_str = r#"
2572 [formatters.python]
2573 preset = "pycln"
2574 "#;
2575 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2576 let fmt = &cfg.formatters.get("python").unwrap()[0];
2577 assert_eq!(fmt.cmd, "pycln");
2578 assert_eq!(fmt.args, vec!["--silence", "-"]);
2579 assert!(fmt.stdin);
2580 }
2581
2582 #[test]
2583 fn preset_resolution_pyproject_fmt() {
2584 let toml_str = r#"
2585 [formatters.toml]
2586 preset = "pyproject-fmt"
2587 "#;
2588 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2589 let fmt = &cfg.formatters.get("toml").unwrap()[0];
2590 assert_eq!(fmt.cmd, "pyproject-fmt");
2591 assert_eq!(fmt.args, vec!["-"]);
2592 assert!(fmt.stdin);
2593 }
2594
2595 #[test]
2596 fn preset_resolution_google_java_format() {
2597 let toml_str = r#"
2598 [formatters.java]
2599 preset = "google-java-format"
2600 "#;
2601 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2602 let fmt = &cfg.formatters.get("java").unwrap()[0];
2603 assert_eq!(fmt.cmd, "google-java-format");
2604 assert_eq!(fmt.args, vec!["-"]);
2605 assert!(fmt.stdin);
2606 }
2607
2608 #[test]
2609 fn preset_resolution_racketfmt() {
2610 let toml_str = r#"
2611 [formatters.racket]
2612 preset = "racketfmt"
2613 "#;
2614 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2615 let fmt = &cfg.formatters.get("racket").unwrap()[0];
2616 assert_eq!(fmt.cmd, "raco");
2617 assert_eq!(fmt.args, vec!["fmt"]);
2618 assert!(fmt.stdin);
2619 }
2620
2621 #[test]
2622 fn preset_resolution_rubyfmt() {
2623 let toml_str = r#"
2624 [formatters.ruby]
2625 preset = "rubyfmt"
2626 "#;
2627 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2628 let fmt = &cfg.formatters.get("ruby").unwrap()[0];
2629 assert_eq!(fmt.cmd, "rubyfmt");
2630 assert!(fmt.args.is_empty());
2631 assert!(fmt.stdin);
2632 }
2633
2634 #[test]
2635 fn preset_resolution_rufo() {
2636 let toml_str = r#"
2637 [formatters.ruby]
2638 preset = "rufo"
2639 "#;
2640 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2641 let fmt = &cfg.formatters.get("ruby").unwrap()[0];
2642 assert_eq!(fmt.cmd, "rufo");
2643 assert!(fmt.args.is_empty());
2644 assert!(fmt.stdin);
2645 }
2646
2647 #[test]
2648 fn preset_resolution_bean_format() {
2649 let toml_str = r#"
2650 [formatters.beancount]
2651 preset = "bean-format"
2652 "#;
2653 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2654 let fmt = &cfg.formatters.get("beancount").unwrap()[0];
2655 assert_eq!(fmt.cmd, "bean-format");
2656 assert_eq!(fmt.args, vec!["-"]);
2657 assert!(fmt.stdin);
2658 }
2659
2660 #[test]
2661 fn preset_resolution_beautysh() {
2662 let toml_str = r#"
2663 [formatters.bash]
2664 preset = "beautysh"
2665 "#;
2666 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2667 let fmt = &cfg.formatters.get("bash").unwrap()[0];
2668 assert_eq!(fmt.cmd, "beautysh");
2669 assert_eq!(fmt.args, vec!["-"]);
2670 assert!(fmt.stdin);
2671 }
2672
2673 #[test]
2674 fn preset_resolution_cljfmt() {
2675 let toml_str = r#"
2676 [formatters.clojure]
2677 preset = "cljfmt"
2678 "#;
2679 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2680 let fmt = &cfg.formatters.get("clojure").unwrap()[0];
2681 assert_eq!(fmt.cmd, "cljfmt");
2682 assert_eq!(fmt.args, vec!["fix", "-"]);
2683 assert!(fmt.stdin);
2684 }
2685
2686 #[test]
2687 fn preset_resolution_fish_indent() {
2688 let toml_str = r#"
2689 [formatters.fish]
2690 preset = "fish_indent"
2691 "#;
2692 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2693 let fmt = &cfg.formatters.get("fish").unwrap()[0];
2694 assert_eq!(fmt.cmd, "fish_indent");
2695 assert!(fmt.args.is_empty());
2696 assert!(fmt.stdin);
2697 }
2698
2699 #[test]
2700 fn preset_resolution_fixjson() {
2701 let toml_str = r#"
2702 [formatters.json]
2703 preset = "fixjson"
2704 "#;
2705 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2706 let fmt = &cfg.formatters.get("json").unwrap()[0];
2707 assert_eq!(fmt.cmd, "fixjson");
2708 assert!(fmt.args.is_empty());
2709 assert!(fmt.stdin);
2710 }
2711
2712 #[test]
2713 fn preset_resolution_bibtex_tidy() {
2714 let toml_str = r#"
2715 [formatters.bibtex]
2716 preset = "bibtex-tidy"
2717 "#;
2718 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2719 let fmt = &cfg.formatters.get("bibtex").unwrap()[0];
2720 assert_eq!(fmt.cmd, "bibtex-tidy");
2721 assert_eq!(fmt.args, vec!["--quiet"]);
2722 assert!(fmt.stdin);
2723 }
2724
2725 #[test]
2726 fn preset_resolution_bpfmt() {
2727 let toml_str = r#"
2728 [formatters.bp]
2729 preset = "bpfmt"
2730 "#;
2731 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2732 let fmt = &cfg.formatters.get("bp").unwrap()[0];
2733 assert_eq!(fmt.cmd, "bpfmt");
2734 assert_eq!(fmt.args, vec!["-w", "{}"]);
2735 assert!(!fmt.stdin);
2736 }
2737
2738 #[test]
2739 fn preset_resolution_bsfmt() {
2740 let toml_str = r#"
2741 [formatters.brs]
2742 preset = "bsfmt"
2743 "#;
2744 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2745 let fmt = &cfg.formatters.get("brs").unwrap()[0];
2746 assert_eq!(fmt.cmd, "bsfmt");
2747 assert_eq!(fmt.args, vec!["{}", "--write"]);
2748 assert!(!fmt.stdin);
2749 }
2750
2751 #[test]
2752 fn preset_resolution_buf() {
2753 let toml_str = r#"
2754 [formatters.proto]
2755 preset = "buf"
2756 "#;
2757 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2758 let fmt = &cfg.formatters.get("proto").unwrap()[0];
2759 assert_eq!(fmt.cmd, "buf");
2760 assert_eq!(fmt.args, vec!["format", "-w", "{}"]);
2761 assert!(!fmt.stdin);
2762 }
2763
2764 #[test]
2765 fn preset_resolution_buildifier() {
2766 let toml_str = r#"
2767 [formatters.bazel]
2768 preset = "buildifier"
2769 "#;
2770 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2771 let fmt = &cfg.formatters.get("bazel").unwrap()[0];
2772 assert_eq!(fmt.cmd, "buildifier");
2773 assert_eq!(fmt.args, vec!["-path", "{}", "-"]);
2774 assert!(fmt.stdin);
2775 }
2776
2777 #[test]
2778 fn preset_resolution_cabal_fmt() {
2779 let toml_str = r#"
2780 [formatters.cabal]
2781 preset = "cabal-fmt"
2782 "#;
2783 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2784 let fmt = &cfg.formatters.get("cabal").unwrap()[0];
2785 assert_eq!(fmt.cmd, "cabal-fmt");
2786 assert_eq!(fmt.args, vec!["--inplace", "{}"]);
2787 assert!(!fmt.stdin);
2788 }
2789
2790 #[test]
2791 fn preset_resolution_prettier_typescript() {
2792 let toml_str = r#"
2793 [formatters.typescript]
2794 preset = "prettier"
2795 "#;
2796 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2797 let fmt = &cfg.formatters.get("typescript").unwrap()[0];
2798 assert_eq!(fmt.cmd, "prettier");
2799 assert_eq!(fmt.args, vec!["--stdin-filepath", "{}"]);
2800 assert!(fmt.stdin);
2801 }
2802
2803 #[test]
2804 fn preset_and_cmd_mutually_exclusive() {
2805 let toml_str = r#"
2806 [formatters.r]
2807 preset = "air"
2808 cmd = "styler"
2809 "#;
2810 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2811 assert!(!cfg.formatters.contains_key("r"));
2813 }
2814
2815 #[test]
2816 fn unknown_preset_fails() {
2817 let toml_str = r#"
2818 [formatters.r]
2819 preset = "nonexistent"
2820 "#;
2821 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2822 assert!(!cfg.formatters.contains_key("r"));
2824 }
2825
2826 #[test]
2827 fn preset_language_mismatch_is_rejected() {
2828 let toml_str = r#"
2829 [formatters]
2830 python = "gofmt"
2831 "#;
2832 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2833 assert!(!cfg.formatters.contains_key("python"));
2834 }
2835
2836 #[test]
2837 fn preset_language_alias_is_accepted() {
2838 let toml_str = r#"
2839 [formatters]
2840 yml = "yamlfmt"
2841 "#;
2842 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2843 let fmts = cfg.formatters.get("yml").unwrap();
2844 assert_eq!(fmts.len(), 1);
2845 assert_eq!(fmts[0].cmd, "yamlfmt");
2846 }
2847
2848 #[test]
2849 fn named_definition_skips_builtin_language_guard() {
2850 let toml_str = r#"
2851 [formatters]
2852 javascript = "prettier"
2853
2854 [formatters.prettier]
2855 cmd = "prettier"
2856 args = ["--print-width=100"]
2857 "#;
2858 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2859 let fmts = cfg.formatters.get("javascript").unwrap();
2860 assert_eq!(fmts.len(), 1);
2861 assert_eq!(fmts[0].cmd, "prettier");
2862 assert_eq!(fmts[0].args, vec!["--print-width=100"]);
2863 }
2864
2865 #[test]
2866 fn preset_metadata_lookup_contains_url() {
2867 let meta = formatter_preset_metadata("gofmt").unwrap();
2868 assert_eq!(meta.name, "gofmt");
2869 assert_eq!(meta.cmd, "gofmt");
2870 assert!(meta.url.contains("pkg.go.dev"));
2871 }
2872
2873 #[test]
2874 fn preset_metadata_language_lookup_works() {
2875 let names: Vec<&str> = formatter_presets_for_language("yaml")
2876 .iter()
2877 .map(|meta| meta.name)
2878 .collect();
2879 assert!(names.contains(&"yamlfmt"));
2880 assert!(names.contains(&"yamlfix"));
2881 assert!(names.contains(&"yq"));
2882 }
2883
2884 #[test]
2885 fn preset_metadata_language_lookup_includes_prettier_for_typescript() {
2886 let names: Vec<&str> = formatter_presets_for_language("typescript")
2887 .iter()
2888 .map(|meta| meta.name)
2889 .collect();
2890 assert!(names.contains(&"prettier"));
2891 }
2892
2893 #[test]
2894 fn builtin_defaults_when_no_config() {
2895 let cfg = Config::default();
2896 assert!(cfg.formatters.is_empty());
2898 assert!(cfg.lint.is_rule_enabled("heading-hierarchy"));
2899 assert!(cfg.lint.is_rule_enabled("undefined-references"));
2900 }
2901
2902 #[test]
2903 fn lint_config_allows_rule_toggles() {
2904 let toml_str = r#"
2905 [lint.rules]
2906 heading-hierarchy = false
2907 undefined-references = false
2908 "#;
2909 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2910 assert!(!cfg.lint.is_rule_enabled("heading-hierarchy"));
2911 assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2912 assert!(cfg.lint.is_rule_enabled("duplicate-reference-labels"));
2913 }
2914
2915 #[test]
2916 fn lint_config_normalizes_snake_case_rule_names() {
2917 let toml_str = r#"
2918 [lint.rules]
2919 undefined_references = false
2920 "#;
2921 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2922 assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2923 }
2924
2925 #[test]
2926 fn lint_config_legacy_top_level_rules_still_supported() {
2927 let toml_str = r#"
2928 [lint]
2929 undefined-references = false
2930 "#;
2931 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2932 assert!(!cfg.lint.is_rule_enabled("undefined-references"));
2933 }
2934
2935 #[test]
2936 fn path_selector_fields_parse() {
2937 let toml_str = r#"
2938 exclude = ["tests/", "build/"]
2939 extend-exclude = ["snapshots/"]
2940 include = ["*.qmd"]
2941 extend-include = ["*.md"]
2942 "#;
2943 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2944 assert_eq!(
2945 cfg.exclude,
2946 Some(vec!["tests/".to_string(), "build/".to_string()])
2947 );
2948 assert_eq!(cfg.extend_exclude, vec!["snapshots/".to_string()]);
2949 assert_eq!(cfg.include, Some(vec!["*.qmd".to_string()]));
2950 assert_eq!(cfg.extend_include, vec!["*.md".to_string()]);
2951 }
2952
2953 #[test]
2954 fn path_selector_fields_default_to_unset_or_empty() {
2955 let cfg = toml::from_str::<Config>("line-width = 100").unwrap();
2956 assert!(cfg.exclude.is_none());
2957 assert!(cfg.extend_exclude.is_empty());
2958 assert!(cfg.include.is_none());
2959 assert!(cfg.extend_include.is_empty());
2960 }
2961
2962 #[test]
2963 fn default_exclude_patterns_include_license_md() {
2964 assert!(DEFAULT_EXCLUDE_PATTERNS.contains(&"**/LICENSE.md"));
2965 }
2966
2967 #[test]
2968 fn flavor_overrides_parse() {
2969 let toml_str = r#"
2970 [flavor-overrides]
2971 "README.md" = "gfm"
2972 "docs/**/*.md" = "quarto"
2973 "#;
2974 let cfg = toml::from_str::<Config>(toml_str).unwrap();
2975 assert_eq!(cfg.flavor_overrides.get("README.md"), Some(&Flavor::Gfm));
2976 assert_eq!(
2977 cfg.flavor_overrides.get("docs/**/*.md"),
2978 Some(&Flavor::Quarto)
2979 );
2980 }
2981
2982 #[test]
2983 fn flavor_override_uses_most_specific_match() {
2984 let mut overrides = HashMap::new();
2985 overrides.insert("docs/**/*.md".to_string(), Flavor::Pandoc);
2986 overrides.insert("docs/README.md".to_string(), Flavor::Gfm);
2987 let input = Path::new("/project/docs/README.md");
2988
2989 let flavor = detect_flavor_override(input, Some(Path::new("/project")), &overrides);
2990 assert_eq!(flavor, Some(Flavor::Gfm));
2991 }
2992
2993 #[test]
2994 fn detect_flavor_uses_override_for_markdown_family() {
2995 let mut cfg = Config {
2996 flavor: Flavor::Pandoc,
2997 ..Config::default()
2998 };
2999 cfg.flavor_overrides
3000 .insert("README.md".to_string(), Flavor::Gfm);
3001
3002 let flavor = detect_flavor(
3003 Some(Path::new("/project/README.md")),
3004 Some(Path::new("/project/panache.toml")),
3005 &cfg,
3006 );
3007 assert_eq!(flavor, Some(Flavor::Gfm));
3008 }
3009
3010 #[test]
3011 fn detect_flavor_keeps_qmd_rmd_extension_defaults() {
3012 let mut cfg = Config {
3013 flavor: Flavor::Gfm,
3014 ..Config::default()
3015 };
3016 cfg.flavor_overrides
3017 .insert("docs/**/*.qmd".to_string(), Flavor::Pandoc);
3018 cfg.flavor_overrides
3019 .insert("docs/**/*.Rmd".to_string(), Flavor::Quarto);
3020
3021 let qmd_flavor = detect_flavor(
3022 Some(Path::new("/project/docs/chapter.qmd")),
3023 Some(Path::new("/project/panache.toml")),
3024 &cfg,
3025 );
3026 let rmd_flavor = detect_flavor(
3027 Some(Path::new("/project/docs/chapter.Rmd")),
3028 Some(Path::new("/project/panache.toml")),
3029 &cfg,
3030 );
3031
3032 assert_eq!(qmd_flavor, Some(Flavor::Quarto));
3033 assert_eq!(rmd_flavor, Some(Flavor::RMarkdown));
3034 }
3035
3036 #[test]
3037 fn user_config_adds_formatters() {
3038 let toml_str = r#"
3039 [formatters.r]
3040 cmd = "custom"
3041 args = ["--flag"]
3042 "#;
3043 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3044
3045 assert_eq!(cfg.formatters.len(), 1);
3047 let r_fmt = &cfg.formatters.get("r").unwrap()[0];
3048 assert_eq!(r_fmt.cmd, "custom");
3049 assert_eq!(r_fmt.args, vec!["--flag"]);
3050 }
3051
3052 #[test]
3053 fn empty_formatters_section_stays_empty() {
3054 let toml_str = r#"
3055 line_width = 100
3056 "#;
3057 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3058
3059 assert!(cfg.formatters.is_empty());
3061 }
3062
3063 #[test]
3064 fn preset_with_enabled_false() {
3065 let toml_str = r#"
3066 [formatters.r]
3067 preset = "air"
3068 enabled = false
3069 "#;
3070 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3071 assert!(!cfg.formatters.contains_key("r"));
3073 }
3074
3075 #[test]
3076 fn default_flavor_is_pandoc() {
3077 let default_cfg = Config::default();
3078 assert_eq!(default_cfg.flavor, Flavor::Pandoc);
3079 }
3080
3081 #[test]
3082 fn extensions_merge_with_flavor_quarto() {
3083 let toml_str = r#"
3085 flavor = "quarto"
3086
3087 [extensions]
3088 quarto-crossrefs = false
3089 "#;
3090 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3091
3092 assert!(!cfg.extensions.quarto_crossrefs);
3094
3095 assert!(cfg.extensions.quarto_callouts);
3097 assert!(cfg.extensions.quarto_shortcodes);
3098
3099 assert!(cfg.extensions.citations);
3101 assert!(cfg.extensions.yaml_metadata_block);
3102 assert!(cfg.extensions.fenced_divs);
3103 }
3104
3105 #[test]
3106 fn extensions_merge_with_flavor_pandoc() {
3107 let toml_str = r#"
3109 flavor = "pandoc"
3110
3111 [extensions]
3112 citations = false
3113 "#;
3114 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3115
3116 assert!(!cfg.extensions.citations);
3118
3119 assert!(cfg.extensions.yaml_metadata_block);
3121 assert!(cfg.extensions.fenced_divs);
3122
3123 assert!(!cfg.extensions.quarto_crossrefs);
3125 assert!(!cfg.extensions.quarto_callouts);
3126 }
3127
3128 #[test]
3129 fn extensions_no_override_uses_flavor_defaults() {
3130 let toml_str = r#"
3132 flavor = "quarto"
3133 "#;
3134 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3135
3136 assert!(cfg.extensions.quarto_crossrefs);
3138 assert!(cfg.extensions.quarto_callouts);
3139 assert!(cfg.extensions.quarto_shortcodes);
3140 }
3141
3142 #[test]
3143 fn extensions_empty_section_uses_flavor_defaults() {
3144 let toml_str = r#"
3146 flavor = "quarto"
3147
3148 [extensions]
3149 "#;
3150 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3151
3152 assert!(cfg.extensions.quarto_crossrefs);
3154 assert!(cfg.extensions.quarto_callouts);
3155 assert!(cfg.extensions.quarto_shortcodes);
3156 }
3157
3158 #[test]
3159 fn extensions_multiple_overrides() {
3160 let toml_str = r#"
3162 flavor = "quarto"
3163
3164 [extensions]
3165 quarto-crossrefs = false
3166 citations = false
3167 emoji = true
3168 "#;
3169 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3170
3171 assert!(!cfg.extensions.quarto_crossrefs);
3173 assert!(!cfg.extensions.citations);
3174 assert!(cfg.extensions.emoji);
3175
3176 assert!(cfg.extensions.quarto_callouts);
3178 assert!(cfg.extensions.quarto_shortcodes);
3179 }
3180
3181 #[test]
3182 fn extensions_per_flavor_override_wins_over_global() {
3183 let toml_str = r#"
3184 flavor = "gfm"
3185
3186 [extensions]
3187 task-lists = false
3188 citations = true
3189
3190 [extensions.gfm]
3191 task-lists = true
3192 "#;
3193 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3194
3195 assert!(cfg.extensions.task_lists);
3196 assert!(cfg.extensions.citations);
3197 }
3198
3199 #[test]
3200 fn extensions_per_flavor_table_is_ignored_for_other_flavors() {
3201 let toml_str = r#"
3202 flavor = "pandoc"
3203
3204 [extensions]
3205 citations = false
3206
3207 [extensions.gfm]
3208 citations = true
3209 "#;
3210 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3211
3212 assert!(!cfg.extensions.citations);
3213 }
3214
3215 #[test]
3216 fn extensions_per_flavor_commonmark_alias_works() {
3217 let toml_str = r#"
3218 flavor = "common-mark"
3219
3220 [extensions]
3221 citations = true
3222
3223 [extensions.commonmark]
3224 citations = false
3225 "#;
3226 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3227
3228 assert!(!cfg.extensions.citations);
3229 }
3230
3231 #[test]
3232 fn multimarkdown_flavor_enables_mmd_header_identifiers_by_default() {
3233 let cfg = toml::from_str::<Config>("flavor = \"multimarkdown\"").unwrap();
3234
3235 assert_eq!(cfg.flavor, Flavor::MultiMarkdown);
3236 assert!(cfg.extensions.mmd_header_identifiers);
3237 assert!(cfg.extensions.mmd_title_block);
3238 assert!(cfg.extensions.mmd_link_attributes);
3239 assert!(!cfg.extensions.pandoc_title_block);
3240 assert!(cfg.extensions.tex_math_double_backslash);
3241 assert!(cfg.extensions.definition_lists);
3242 assert!(cfg.extensions.raw_attribute);
3243 }
3244
3245 #[test]
3246 fn extensions_per_flavor_multimarkdown_table_works() {
3247 let toml_str = r#"
3248 flavor = "multimarkdown"
3249
3250 [extensions]
3251 mmd-header-identifiers = false
3252
3253 [extensions.multimarkdown]
3254 mmd-header-identifiers = true
3255 "#;
3256 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3257
3258 assert!(cfg.extensions.mmd_header_identifiers);
3259 }
3260
3261 #[test]
3262 fn extensions_per_flavor_multimarkdown_title_block_override_works() {
3263 let toml_str = r#"
3264 flavor = "multimarkdown"
3265
3266 [extensions]
3267 mmd-title-block = false
3268
3269 [extensions.multimarkdown]
3270 mmd-title-block = true
3271 "#;
3272 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3273
3274 assert!(cfg.extensions.mmd_title_block);
3275 }
3276
3277 #[test]
3278 fn extensions_per_flavor_multimarkdown_link_attributes_override_works() {
3279 let toml_str = r#"
3280 flavor = "multimarkdown"
3281
3282 [extensions]
3283 mmd-link-attributes = false
3284
3285 [extensions.multimarkdown]
3286 mmd-link-attributes = true
3287 "#;
3288 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3289
3290 assert!(cfg.extensions.mmd_link_attributes);
3291 }
3292
3293 #[test]
3294 fn alerts_enabled_by_default_for_gfm() {
3295 let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3296 assert!(cfg.extensions.alerts);
3297 }
3298
3299 #[test]
3300 fn auto_identifiers_enabled_by_default_for_pandoc_and_gfm() {
3301 let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3302 let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3303 assert!(pandoc.extensions.auto_identifiers);
3304 assert!(gfm.extensions.auto_identifiers);
3305 }
3306
3307 #[test]
3308 fn gfm_auto_identifiers_enabled_by_default_only_for_gfm() {
3309 let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3310 let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3311 let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3312
3313 assert!(!pandoc.extensions.gfm_auto_identifiers);
3314 assert!(gfm.extensions.gfm_auto_identifiers);
3315 assert!(!commonmark.extensions.gfm_auto_identifiers);
3316 }
3317
3318 #[test]
3319 fn footnotes_enabled_by_default_for_gfm() {
3320 let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3321 assert!(cfg.extensions.footnotes);
3322 }
3323
3324 #[test]
3325 fn fenced_code_blocks_enabled_by_default_for_gfm() {
3326 let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3327 assert!(cfg.extensions.backtick_code_blocks);
3328 assert!(cfg.extensions.fenced_code_blocks);
3329 }
3330
3331 #[test]
3332 fn tex_math_gfm_enabled_by_default_for_gfm() {
3333 let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3334 assert!(cfg.extensions.tex_math_gfm);
3335 }
3336
3337 #[test]
3338 fn executable_code_enabled_by_default_for_quarto_and_rmarkdown() {
3339 let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
3340 let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
3341
3342 assert!(quarto.extensions.executable_code);
3343 assert!(rmarkdown.extensions.executable_code);
3344 assert!(quarto.extensions.quarto_inline_code);
3345 assert!(quarto.extensions.rmarkdown_inline_code);
3346 assert!(rmarkdown.extensions.rmarkdown_inline_code);
3347 assert!(!rmarkdown.extensions.quarto_inline_code);
3348 }
3349
3350 #[test]
3351 fn bookdown_equation_references_enabled_by_default_only_for_rmarkdown() {
3352 let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3353 let quarto = toml::from_str::<Config>("flavor = \"quarto\"").unwrap();
3354 let rmarkdown = toml::from_str::<Config>("flavor = \"rmarkdown\"").unwrap();
3355
3356 assert!(!pandoc.extensions.bookdown_equation_references);
3357 assert!(!quarto.extensions.bookdown_equation_references);
3358 assert!(rmarkdown.extensions.bookdown_equation_references);
3359 }
3360
3361 #[test]
3362 fn executable_code_disabled_by_default_for_pandoc_gfm_and_commonmark() {
3363 let pandoc = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3364 let gfm = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3365 let commonmark = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3366
3367 assert!(!pandoc.extensions.executable_code);
3368 assert!(!gfm.extensions.executable_code);
3369 assert!(!commonmark.extensions.executable_code);
3370 assert!(!pandoc.extensions.quarto_inline_code);
3371 assert!(!pandoc.extensions.rmarkdown_inline_code);
3372 assert!(!gfm.extensions.quarto_inline_code);
3373 assert!(!gfm.extensions.rmarkdown_inline_code);
3374 assert!(!commonmark.extensions.quarto_inline_code);
3375 assert!(!commonmark.extensions.rmarkdown_inline_code);
3376 }
3377
3378 #[test]
3379 fn gfm_disables_non_gfm_pandoc_extensions() {
3380 let cfg = toml::from_str::<Config>("flavor = \"gfm\"").unwrap();
3381 assert!(!cfg.extensions.citations);
3382 assert!(!cfg.extensions.definition_lists);
3383 assert!(!cfg.extensions.fenced_divs);
3384 assert!(!cfg.extensions.raw_tex);
3385 }
3386
3387 #[test]
3388 fn commonmark_defaults_match_minimal_set() {
3389 let cfg = toml::from_str::<Config>("flavor = \"common-mark\"").unwrap();
3390 assert!(cfg.extensions.raw_html);
3391 assert!(!cfg.extensions.auto_identifiers);
3392 assert!(!cfg.extensions.autolinks);
3393 assert!(!cfg.extensions.inline_links);
3394 assert!(!cfg.extensions.reference_links);
3395 }
3396
3397 #[test]
3398 fn alerts_disabled_by_default_for_pandoc() {
3399 let cfg = toml::from_str::<Config>("flavor = \"pandoc\"").unwrap();
3400 assert!(!cfg.extensions.alerts);
3401 }
3402
3403 #[test]
3404 fn format_section_new_format() {
3405 let toml_str = r#"
3406 flavor = "quarto"
3407
3408 [format]
3409 wrap = "sentence"
3410 built-in-greedy-wrap = true
3411 blank-lines = "collapse"
3412 math-delimiter-style = "dollars"
3413 math-indent = 2
3414 tab-stops = "preserve"
3415 tab-width = 4
3416 "#;
3417 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3418
3419 assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
3420 assert_eq!(cfg.blank_lines, BlankLines::Collapse);
3421 assert_eq!(cfg.math_delimiter_style, MathDelimiterStyle::Dollars);
3422 assert_eq!(cfg.math_indent, 2);
3423 assert_eq!(cfg.tab_stops, TabStopMode::Preserve);
3424 assert_eq!(cfg.tab_width, 4);
3425 assert!(cfg.built_in_greedy_wrap);
3426 }
3427
3428 #[test]
3429 fn format_section_with_deprecated_code_blocks_is_accepted() {
3430 let toml_str = r#"
3431 flavor = "pandoc"
3432
3433 [format]
3434 wrap = "preserve"
3435
3436 [format.code-blocks]
3437 fence-style = "tilde"
3438 attribute-style = "explicit"
3439 "#;
3440 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3441
3442 assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
3443 assert!(cfg.built_in_greedy_wrap);
3444 }
3445
3446 #[test]
3447 fn backwards_compat_old_format_still_works() {
3448 let toml_str = r#"
3449 flavor = "quarto"
3450 wrap = "reflow"
3451 math-indent = 4
3452
3453 [code-blocks]
3454 fence-style = "backtick"
3455 "#;
3456 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3457
3458 assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
3460 assert_eq!(cfg.math_indent, 4);
3461 }
3462
3463 #[test]
3464 fn format_section_takes_precedence() {
3465 let toml_str = r#"
3466 flavor = "quarto"
3467
3468 # Old format (should be ignored)
3469 wrap = "preserve"
3470 math-indent = 10
3471
3472 # New format (should take precedence)
3473 [format]
3474 wrap = "sentence"
3475 math-indent = 2
3476 "#;
3477 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3478
3479 assert_eq!(cfg.wrap, Some(WrapMode::Sentence));
3481 assert_eq!(cfg.math_indent, 2);
3482 }
3483
3484 #[test]
3485 fn deprecated_style_section_still_supported() {
3486 let toml_str = r#"
3487 [style]
3488 wrap = "preserve"
3489 "#;
3490 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3491 assert_eq!(cfg.wrap, Some(WrapMode::Preserve));
3492 }
3493}
3494
3495#[cfg(test)]
3496mod line_ending_test {
3497 use super::*;
3498
3499 #[test]
3500 fn test_deserialize_line_ending_in_config() {
3501 #[derive(Deserialize)]
3502 struct TestConfig {
3503 line_ending: LineEnding,
3504 }
3505
3506 let cfg: TestConfig = toml::from_str(r#"line_ending = "lf""#).unwrap();
3507 assert_eq!(cfg.line_ending, LineEnding::Lf);
3508
3509 let cfg2: TestConfig = toml::from_str(r#"line_ending = "auto""#).unwrap();
3510 assert_eq!(cfg2.line_ending, LineEnding::Auto);
3511
3512 let cfg3: TestConfig = toml::from_str(r#"line_ending = "crlf""#).unwrap();
3513 assert_eq!(cfg3.line_ending, LineEnding::Crlf);
3514 }
3515}
3516
3517#[cfg(test)]
3518mod raw_config_test {
3519 use super::*;
3520
3521 #[test]
3522 fn test_raw_config_line_ending() {
3523 let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
3525 assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
3526
3527 let content = r#"
3529 line-ending = "crlf"
3530 line-width = 100
3531 "#;
3532 let cfg2: Config = toml::from_str(content).unwrap();
3533 assert_eq!(cfg2.line_ending, Some(LineEnding::Crlf));
3534 assert_eq!(cfg2.line_width, 100);
3535 }
3536}
3537
3538#[cfg(test)]
3539mod field_name_test {
3540 use super::*;
3541
3542 #[test]
3543 fn test_line_ending_field_name() {
3544 let cfg: Config = toml::from_str(r#"line-ending = "lf""#).unwrap();
3546 assert_eq!(cfg.line_ending, Some(LineEnding::Lf));
3547
3548 let cfg_auto: Config = toml::from_str(r#"line-ending = "auto""#).unwrap();
3550 assert_eq!(cfg_auto.line_ending, Some(LineEnding::Auto));
3551
3552 let cfg_crlf: Config = toml::from_str(r#"line-ending = "crlf""#).unwrap();
3553 assert_eq!(cfg_crlf.line_ending, Some(LineEnding::Crlf));
3554 }
3555}
3556
3557#[cfg(test)]
3558mod code_blocks_config_test {
3559 use super::*;
3560
3561 #[test]
3562 fn deprecated_top_level_code_blocks_is_accepted_as_noop() {
3563 let toml_str = r#"
3564 flavor = "pandoc"
3565
3566 [code-blocks]
3567 attribute-style = "explicit"
3568 "#;
3569 let cfg: Config = toml::from_str(toml_str).unwrap();
3570 assert_eq!(cfg.flavor, Flavor::Pandoc);
3571 }
3572
3573 #[test]
3574 fn deprecated_format_code_blocks_is_accepted_as_noop() {
3575 let toml_str = r#"
3576 flavor = "quarto"
3577 [format]
3578 wrap = "reflow"
3579
3580 [format.code-blocks]
3581 attribute-style = "explicit"
3582 "#;
3583 let cfg: Config = toml::from_str(toml_str).unwrap();
3584 assert_eq!(cfg.wrap, Some(WrapMode::Reflow));
3585 }
3586
3587 #[test]
3588 fn no_code_blocks_config_still_parses() {
3589 let toml_str = r#"
3590 flavor = "quarto"
3591 "#;
3592 let cfg: Config = toml::from_str(toml_str).unwrap();
3593 assert_eq!(cfg.flavor, Flavor::Quarto);
3594 }
3595
3596 #[test]
3599 fn new_format_single_formatter() {
3600 let toml_str = r#"
3601 [formatters]
3602 r = "air"
3603 python = "black"
3604 "#;
3605 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3606
3607 assert_eq!(cfg.formatters.len(), 2);
3608
3609 let r_fmts = cfg.formatters.get("r").unwrap();
3611 assert_eq!(r_fmts.len(), 1);
3612 assert_eq!(r_fmts[0].cmd, "air");
3613 assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3614
3615 let py_fmts = cfg.formatters.get("python").unwrap();
3617 assert_eq!(py_fmts.len(), 1);
3618 assert_eq!(py_fmts[0].cmd, "black");
3619 }
3620
3621 #[test]
3622 fn new_format_multiple_formatters() {
3623 let toml_str = r#"
3624 [formatters]
3625 python = ["ruff", "black"]
3626 "#;
3627 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3628
3629 let py_fmts = cfg.formatters.get("python").unwrap();
3630 assert_eq!(py_fmts.len(), 2);
3631 assert_eq!(py_fmts[0].cmd, "ruff");
3632 assert_eq!(py_fmts[1].cmd, "black");
3633 }
3634
3635 #[test]
3636 fn new_format_with_custom_definition() {
3637 let toml_str = r#"
3638 [formatters]
3639 r = "custom-air"
3640
3641 [formatters.custom-air]
3642 cmd = "air"
3643 args = ["format", "--custom-flag"]
3644 "#;
3645 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3646
3647 let r_fmts = cfg.formatters.get("r").unwrap();
3648 assert_eq!(r_fmts.len(), 1);
3649 assert_eq!(r_fmts[0].cmd, "air");
3650 assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag"]);
3651 }
3652
3653 #[test]
3654 fn new_format_empty_array() {
3655 let toml_str = r#"
3656 [formatters]
3657 r = []
3658 "#;
3659 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3660
3661 assert!(!cfg.formatters.contains_key("r"));
3663 }
3664
3665 #[test]
3666 fn new_format_reusable_definition() {
3667 let toml_str = r#"
3668 [formatters]
3669 javascript = "prettier"
3670 typescript = "prettier"
3671 json = "prettier"
3672
3673 [formatters.prettier]
3674 cmd = "prettier"
3675 args = ["--print-width=100"]
3676 "#;
3677 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3678
3679 assert_eq!(cfg.formatters.len(), 3);
3680
3681 for lang in ["javascript", "typescript", "json"] {
3683 let fmts = cfg.formatters.get(lang).unwrap();
3684 assert_eq!(fmts.len(), 1);
3685 assert_eq!(fmts[0].cmd, "prettier");
3686 assert_eq!(fmts[0].args, vec!["--print-width=100"]);
3687 }
3688 }
3689
3690 #[test]
3693 fn preset_inheritance_override_only_args() {
3694 let toml_str = r#"
3695 [formatters]
3696 r = "air"
3697
3698 [formatters.air]
3699 args = ["format", "--custom-flag", "{}"]
3700 "#;
3701 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3702
3703 let r_fmts = cfg.formatters.get("r").unwrap();
3704 assert_eq!(r_fmts.len(), 1);
3705 assert_eq!(r_fmts[0].cmd, "air");
3707 assert!(!r_fmts[0].stdin);
3708 assert_eq!(r_fmts[0].args, vec!["format", "--custom-flag", "{}"]);
3710 }
3711
3712 #[test]
3713 fn preset_inheritance_override_only_cmd() {
3714 let toml_str = r#"
3715 [formatters]
3716 r = "air"
3717
3718 [formatters.air]
3719 cmd = "custom-air"
3720 "#;
3721 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3722
3723 let r_fmts = cfg.formatters.get("r").unwrap();
3724 assert_eq!(r_fmts.len(), 1);
3725 assert_eq!(r_fmts[0].cmd, "custom-air");
3727 assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3729 assert!(!r_fmts[0].stdin);
3730 }
3731
3732 #[test]
3733 fn preset_inheritance_override_only_stdin() {
3734 let toml_str = r#"
3735 [formatters]
3736 r = "air"
3737
3738 [formatters.air]
3739 stdin = true
3740 "#;
3741 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3742
3743 let r_fmts = cfg.formatters.get("r").unwrap();
3744 assert_eq!(r_fmts.len(), 1);
3745 assert_eq!(r_fmts[0].cmd, "air");
3747 assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3748 assert!(r_fmts[0].stdin);
3750 }
3751
3752 #[test]
3753 fn preset_inheritance_override_multiple_fields() {
3754 let toml_str = r#"
3755 [formatters]
3756 python = "black"
3757
3758 [formatters.black]
3759 args = ["--line-length=100"]
3760 stdin = false
3761 "#;
3762 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3763
3764 let py_fmts = cfg.formatters.get("python").unwrap();
3765 assert_eq!(py_fmts.len(), 1);
3766 assert_eq!(py_fmts[0].cmd, "black");
3768 assert_eq!(py_fmts[0].args, vec!["--line-length=100"]);
3770 assert!(!py_fmts[0].stdin);
3771 }
3772
3773 #[test]
3774 fn preset_inheritance_override_all_fields() {
3775 let toml_str = r#"
3776 [formatters]
3777 r = "air"
3778
3779 [formatters.air]
3780 cmd = "totally-different"
3781 args = ["custom"]
3782 stdin = true
3783 "#;
3784 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3785
3786 let r_fmts = cfg.formatters.get("r").unwrap();
3787 assert_eq!(r_fmts.len(), 1);
3788 assert_eq!(r_fmts[0].cmd, "totally-different");
3790 assert_eq!(r_fmts[0].args, vec!["custom"]);
3791 assert!(r_fmts[0].stdin);
3792 }
3793
3794 #[test]
3795 fn preset_inheritance_empty_definition_uses_preset() {
3796 let toml_str = r#"
3797 [formatters]
3798 r = "air"
3799
3800 [formatters.air]
3801 # Empty definition - should use preset as-is
3802 "#;
3803 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3804
3805 let r_fmts = cfg.formatters.get("r").unwrap();
3806 assert_eq!(r_fmts.len(), 1);
3807 assert_eq!(r_fmts[0].cmd, "air");
3809 assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3810 assert!(!r_fmts[0].stdin);
3811 }
3812
3813 #[test]
3814 fn preset_inheritance_unknown_name_without_cmd_errors() {
3815 let toml_str = r#"
3816 [formatters]
3817 r = "unknown-formatter"
3818
3819 [formatters.unknown-formatter]
3820 args = ["--flag"]
3821 "#;
3822 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3823
3824 assert!(!cfg.formatters.contains_key("r"));
3827 }
3828
3829 #[test]
3830 fn preset_inheritance_unknown_name_with_cmd_works() {
3831 let toml_str = r#"
3832 [formatters]
3833 r = "unknown-formatter"
3834
3835 [formatters.unknown-formatter]
3836 cmd = "my-custom-formatter"
3837 args = ["--flag"]
3838 "#;
3839 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3840
3841 let r_fmts = cfg.formatters.get("r").unwrap();
3842 assert_eq!(r_fmts.len(), 1);
3843 assert_eq!(r_fmts[0].cmd, "my-custom-formatter");
3845 assert_eq!(r_fmts[0].args, vec!["--flag"]);
3846 assert!(r_fmts[0].stdin); }
3848
3849 #[test]
3852 fn append_args_with_preset_inheritance() {
3853 let toml_str = r#"
3854 [formatters]
3855 r = "air"
3856
3857 [formatters.air]
3858 append-args = ["-i", "2"]
3859 "#;
3860 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3861
3862 let r_fmts = cfg.formatters.get("r").unwrap();
3863 assert_eq!(r_fmts.len(), 1);
3864 assert_eq!(r_fmts[0].cmd, "air");
3867 assert_eq!(r_fmts[0].args, vec!["format", "{}", "-i", "2"]);
3868 assert!(!r_fmts[0].stdin);
3869 }
3870
3871 #[test]
3872 fn prepend_args_with_preset_inheritance() {
3873 let toml_str = r#"
3874 [formatters]
3875 r = "air"
3876
3877 [formatters.air]
3878 prepend-args = ["--verbose"]
3879 "#;
3880 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3881
3882 let r_fmts = cfg.formatters.get("r").unwrap();
3883 assert_eq!(r_fmts.len(), 1);
3884 assert_eq!(r_fmts[0].cmd, "air");
3887 assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}"]);
3888 assert!(!r_fmts[0].stdin);
3889 }
3890
3891 #[test]
3892 fn both_prepend_and_append_args() {
3893 let toml_str = r#"
3894 [formatters]
3895 r = "air"
3896
3897 [formatters.air]
3898 prepend_args = ["--verbose"]
3899 append_args = ["-i", "2"]
3900 "#;
3901 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3902
3903 let r_fmts = cfg.formatters.get("r").unwrap();
3904 assert_eq!(r_fmts.len(), 1);
3905 assert_eq!(r_fmts[0].args, vec!["--verbose", "format", "{}", "-i", "2"]);
3908 }
3909
3910 #[test]
3911 fn append_args_with_explicit_args() {
3912 let toml_str = r#"
3913 [formatters]
3914 r = "custom"
3915
3916 [formatters.custom]
3917 cmd = "shfmt"
3918 args = ["-filename", "$FILENAME"]
3919 append_args = ["-i", "2"]
3920 "#;
3921 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3922
3923 let r_fmts = cfg.formatters.get("r").unwrap();
3924 assert_eq!(r_fmts.len(), 1);
3925 assert_eq!(r_fmts[0].cmd, "shfmt");
3927 assert_eq!(r_fmts[0].args, vec!["-filename", "$FILENAME", "-i", "2"]);
3928 }
3929
3930 #[test]
3931 fn prepend_args_with_explicit_args() {
3932 let toml_str = r#"
3933 [formatters]
3934 r = "custom"
3935
3936 [formatters.custom]
3937 cmd = "formatter"
3938 args = ["input.txt"]
3939 prepend_args = ["--config", "cfg.toml"]
3940 "#;
3941 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3942
3943 let r_fmts = cfg.formatters.get("r").unwrap();
3944 assert_eq!(r_fmts.len(), 1);
3945 assert_eq!(r_fmts[0].args, vec!["--config", "cfg.toml", "input.txt"]);
3947 }
3948
3949 #[test]
3950 fn args_override_with_append_still_applies() {
3951 let toml_str = r#"
3952 [formatters]
3953 r = "air"
3954
3955 [formatters.air]
3956 args = ["custom", "override"]
3957 append_args = ["--extra"]
3958 "#;
3959 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3960
3961 let r_fmts = cfg.formatters.get("r").unwrap();
3962 assert_eq!(r_fmts.len(), 1);
3963 assert_eq!(r_fmts[0].args, vec!["custom", "override", "--extra"]);
3965 }
3966
3967 #[test]
3968 fn empty_append_prepend_arrays() {
3969 let toml_str = r#"
3970 [formatters]
3971 r = "air"
3972
3973 [formatters.air]
3974 prepend_args = []
3975 append_args = []
3976 "#;
3977 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3978
3979 let r_fmts = cfg.formatters.get("r").unwrap();
3980 assert_eq!(r_fmts.len(), 1);
3981 assert_eq!(r_fmts[0].args, vec!["format", "{}"]);
3983 }
3984
3985 #[test]
3986 fn modifiers_without_base_args() {
3987 let toml_str = r#"
3988 [formatters]
3989 r = "custom"
3990
3991 [formatters.custom]
3992 cmd = "formatter"
3993 prepend_args = ["--flag"]
3994 append_args = ["--other"]
3995 "#;
3996 let cfg = toml::from_str::<Config>(toml_str).unwrap();
3997
3998 let r_fmts = cfg.formatters.get("r").unwrap();
3999 assert_eq!(r_fmts.len(), 1);
4000 assert_eq!(r_fmts[0].args, vec!["--flag", "--other"]);
4003 }
4004}
4005
4006#[cfg(test)]
4007mod parser_config_test {
4008 use super::*;
4009
4010 #[test]
4011 fn test_parser_config_default() {
4012 let cfg = Config::default();
4013 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4014 }
4015
4016 #[test]
4017 fn test_parser_config_empty() {
4018 let toml_str = r#"
4020 [parser]
4021 "#;
4022 let cfg: Config = toml::from_str(toml_str).unwrap();
4023 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4024 }
4025
4026 #[test]
4027 fn test_parser_config_pandoc_compat_latest() {
4028 let toml_str = r#"
4029 pandoc-compat = "latest"
4030 "#;
4031 let cfg: Config = toml::from_str(toml_str).unwrap();
4032 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
4033 assert_eq!(cfg.parser.effective_pandoc_compat(), PandocCompat::V3_9);
4034 }
4035
4036 #[test]
4037 fn test_parser_config_pandoc_compat_accepts_version_aliases() {
4038 for value in ["3.7", "3-7", "v3.7", "v3-7"] {
4039 let toml_str = format!("pandoc-compat = \"{}\"\n", value);
4040 let cfg: Config = toml::from_str(&toml_str).unwrap();
4041 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
4042 }
4043
4044 for value in ["3.9", "3-9", "v3.9", "v3-9"] {
4045 let toml_str = format!("pandoc-compat = \"{}\"\n", value);
4046 let cfg: Config = toml::from_str(&toml_str).unwrap();
4047 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_9);
4048 }
4049 }
4050
4051 #[test]
4052 fn test_parser_config_pandoc_compat_parser_section_backwards_compat() {
4053 let toml_str = r#"
4054 [parser]
4055 pandoc-compat = "latest"
4056 "#;
4057 let cfg: Config = toml::from_str(toml_str).unwrap();
4058 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::Latest);
4059 }
4060
4061 #[test]
4062 fn test_parser_config_pandoc_compat_top_level_takes_precedence() {
4063 let toml_str = r#"
4064 pandoc-compat = "3.7"
4065
4066 [parser]
4067 pandoc-compat = "latest"
4068 "#;
4069 let cfg: Config = toml::from_str(toml_str).unwrap();
4070 assert_eq!(cfg.parser.pandoc_compat, PandocCompat::V3_7);
4071 }
4072}
4073
4074#[test]
4075fn test_snake_case_alias_backwards_compat() {
4076 let toml_str = r#"
4078 flavor = "quarto"
4079
4080 [extensions]
4081 quarto_crossrefs = false
4082 tex_math_dollars = true
4083 tex_math_gfm = true
4084 "#;
4085 let cfg = toml::from_str::<Config>(toml_str).unwrap();
4086
4087 assert!(!cfg.extensions.quarto_crossrefs);
4088 assert!(cfg.extensions.tex_math_dollars);
4089 assert!(cfg.extensions.tex_math_gfm);
4090}
4091
4092#[test]
4093fn test_kebab_case_new_format() {
4094 let toml_str = r#"
4096 flavor = "quarto"
4097
4098 [extensions]
4099 quarto-crossrefs = false
4100 tex-math-dollars = true
4101 tex-math-gfm = true
4102 "#;
4103 let cfg = toml::from_str::<Config>(toml_str).unwrap();
4104
4105 assert!(!cfg.extensions.quarto_crossrefs);
4106 assert!(cfg.extensions.tex_math_dollars);
4107 assert!(cfg.extensions.tex_math_gfm);
4108}
4109
4110#[test]
4111fn test_formatter_prepend_append_args_snake_case() {
4112 let toml_str = r#"
4114 [formatters.test]
4115 cmd = "test"
4116 args = ["--middle"]
4117 prepend_args = ["--before"]
4118 append_args = ["--after"]
4119 "#;
4120 let cfg = toml::from_str::<Config>(toml_str).unwrap();
4121 let fmt = &cfg.formatters.get("test").unwrap()[0];
4122
4123 assert_eq!(fmt.cmd, "test");
4124 assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
4125}
4126
4127#[test]
4128fn test_formatter_prepend_append_args_kebab_case() {
4129 let toml_str = r#"
4131 [formatters.test]
4132 cmd = "test"
4133 args = ["--middle"]
4134 prepend-args = ["--before"]
4135 append-args = ["--after"]
4136 "#;
4137 let cfg = toml::from_str::<Config>(toml_str).unwrap();
4138 let fmt = &cfg.formatters.get("test").unwrap()[0];
4139
4140 assert_eq!(fmt.cmd, "test");
4141 assert_eq!(fmt.args, vec!["--before", "--middle", "--after"]);
4142}