1use std::collections::{HashMap, HashSet};
22use std::error::Error as StdError;
23use std::fmt;
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28use mdwright_document::{
29 ExtensionOptions, GfmAutolinkPolicy, GfmOptions, MathDelimiterSet, MathParseOptions, MystOptions, PandocOptions,
30 ParseOptions, RenderOptions, RenderProfile,
31};
32use mdwright_format::{
33 EndOfLine, FmtOptions, HeadingAttrsStyle, ItalicStyle, LinkDefStyle, ListContinuationIndent, ListMarkerStyle,
34 MathOptions, MathRender, OrderedListStyle, Placement, StrongStyle, TableStyle, ThematicStyle, TrailingNewline,
35 Wrap, WrapStrategy,
36};
37use mdwright_lint::RuleSet;
38use mdwright_mathrender::Renderer;
39use serde::de::{Error as DeError, Visitor};
40use serde::{Deserialize, Deserializer};
41
42#[derive(Debug, Clone)]
50pub struct Config {
51 lint_rule_selection: LintRuleSelection,
52 exclude_globs: Vec<String>,
53 extra_info_strings: Vec<String>,
54 render_lint_options: LintRenderOptions,
55 fmt_options: FmtOptions,
56 parse_options: ParseOptions,
57 render_options: RenderOptions,
58 source: Option<PathBuf>,
61}
62
63impl Config {
64 pub fn load_explicit(path: &Path) -> Result<Self, ConfigError> {
72 read_mdwright_toml(path)
73 }
74
75 pub fn discover(cwd: &Path) -> Result<Self, ConfigError> {
91 match discover_walk(cwd)? {
92 Some(cfg) => Ok(cfg),
93 None => Ok(Self::from_schema(Schema::default(), None)),
94 }
95 }
96
97 #[must_use]
100 pub fn source(&self) -> Option<&Path> {
101 self.source.as_deref()
102 }
103
104 #[must_use]
109 pub fn source_dir(&self) -> Option<&Path> {
110 self.source.as_deref().and_then(Path::parent)
111 }
112
113 #[must_use]
115 pub fn lint_rule_selection(&self) -> &LintRuleSelection {
116 &self.lint_rule_selection
117 }
118
119 #[must_use]
122 pub fn exclude_globs(&self) -> &[String] {
123 &self.exclude_globs
124 }
125
126 #[must_use]
129 pub fn extra_info_strings(&self) -> &[String] {
130 &self.extra_info_strings
131 }
132
133 #[must_use]
138 pub fn render_lint_options(&self) -> &LintRenderOptions {
139 &self.render_lint_options
140 }
141
142 #[must_use]
145 pub fn fmt_options(&self) -> &FmtOptions {
146 &self.fmt_options
147 }
148
149 #[must_use]
151 pub fn parse_options(&self) -> ParseOptions {
152 self.parse_options
153 }
154
155 #[must_use]
157 pub fn render_options(&self) -> RenderOptions {
158 self.render_options
159 }
160
161 #[must_use]
167 pub fn defaults() -> Self {
168 Self::from_schema(Schema::default(), None)
169 }
170
171 fn from_schema(schema: Schema, source: Option<PathBuf>) -> Self {
172 let Schema {
173 lint,
174 fmt,
175 parse,
176 render,
177 } = schema;
178 Self {
179 lint_rule_selection: LintRuleSelection {
180 preset: LintRulePreset::from(lint.preset),
181 select: lint.select,
182 extend_select: lint.extend_select,
183 ignore: lint.ignore,
184 },
185 exclude_globs: lint.exclude,
186 extra_info_strings: lint.info_strings.extra,
187 render_lint_options: lint.render.into(),
188 fmt_options: fmt_options_from_schema(fmt),
189 parse_options: parse_options_from_schema(parse),
190 render_options: render_options_from_schema(render),
191 source,
192 }
193 }
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct LintRenderOptions {
200 renderer: Renderer,
201 packages: Vec<String>,
202 macros: HashMap<String, u8>,
203}
204
205impl Default for LintRenderOptions {
206 fn default() -> Self {
207 Self {
208 renderer: Renderer::MathJaxV3,
209 packages: Vec::new(),
210 macros: HashMap::new(),
211 }
212 }
213}
214
215impl LintRenderOptions {
216 #[must_use]
218 pub const fn renderer(&self) -> Renderer {
219 self.renderer
220 }
221
222 #[must_use]
225 pub fn packages(&self) -> &[String] {
226 &self.packages
227 }
228
229 #[must_use]
234 pub fn macros(&self) -> &HashMap<String, u8> {
235 &self.macros
236 }
237}
238
239#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
241pub enum LintRulePreset {
242 #[default]
244 Default,
245 All,
247 None,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct LintRuleSelection {
254 preset: LintRulePreset,
255 select: Vec<String>,
256 extend_select: Vec<String>,
257 ignore: Vec<String>,
258}
259
260impl LintRuleSelection {
261 #[must_use]
262 pub fn preset(&self) -> LintRulePreset {
263 self.preset
264 }
265
266 #[must_use]
267 pub fn select(&self) -> &[String] {
268 &self.select
269 }
270
271 #[must_use]
272 pub fn extend_select(&self) -> &[String] {
273 &self.extend_select
274 }
275
276 #[must_use]
277 pub fn ignore(&self) -> &[String] {
278 &self.ignore
279 }
280
281 pub fn resolve(&self, available: RuleSet) -> Result<RuleSet, RuleSelectionError> {
293 if self.preset != LintRulePreset::None && !self.select.is_empty() {
294 return Err(RuleSelectionError::new(
295 "`lint.select` can only be used with `lint.preset = \"none\"`; use `extend-select` to add rules to a preset",
296 ));
297 }
298
299 let inventory: Vec<(String, bool)> = available
300 .iter()
301 .map(|r| (r.name().to_owned(), r.is_default()))
302 .collect();
303 let all_names: HashSet<&str> = inventory.iter().map(|(name, _)| name.as_str()).collect();
304 let default_names: HashSet<&str> = inventory
305 .iter()
306 .filter_map(|(name, is_default)| is_default.then_some(name.as_str()))
307 .collect();
308
309 let mut selected: HashSet<String> = match self.preset {
310 LintRulePreset::Default => default_names.iter().map(|name| (*name).to_owned()).collect(),
311 LintRulePreset::All => all_names.iter().map(|name| (*name).to_owned()).collect(),
312 LintRulePreset::None => HashSet::new(),
313 };
314
315 for name in &self.select {
316 ensure_known_rule(name, &all_names)?;
317 selected.insert(name.clone());
318 }
319 for name in &self.extend_select {
320 ensure_known_rule(name, &all_names)?;
321 selected.insert(name.clone());
322 }
323 for name in &self.ignore {
324 ensure_known_rule(name, &all_names)?;
325 selected.remove(name);
326 }
327
328 let mut result = RuleSet::new();
329 for rule in available {
330 if selected.contains(rule.name()) {
331 result
332 .add(rule)
333 .map_err(|err| RuleSelectionError::new(err.to_string()))?;
334 }
335 }
336 Ok(result)
337 }
338}
339
340fn ensure_known_rule(name: &str, known: &HashSet<&str>) -> Result<(), RuleSelectionError> {
341 if known.contains(name) {
342 Ok(())
343 } else {
344 Err(RuleSelectionError::new(format!(
345 "unknown lint rule `{name}` (run `mdwright list-rules` to see what's registered)"
346 )))
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct RuleSelectionError {
354 message: String,
355}
356
357impl RuleSelectionError {
358 fn new(message: impl Into<String>) -> Self {
359 Self {
360 message: message.into(),
361 }
362 }
363}
364
365impl fmt::Display for RuleSelectionError {
366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367 f.write_str(&self.message)
368 }
369}
370
371impl StdError for RuleSelectionError {}
372
373#[derive(Debug)]
377pub struct ConfigError {
378 message: String,
379}
380
381impl ConfigError {
382 fn io(path: &Path, err: &io::Error) -> Self {
383 Self {
384 message: format!("read {}: {err}", path.display()),
385 }
386 }
387
388 fn parse(path: &Path, err: &toml::de::Error) -> Self {
389 Self {
390 message: format!("parse {}: {err}", path.display()),
391 }
392 }
393}
394
395impl fmt::Display for ConfigError {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 f.write_str(&self.message)
398 }
399}
400
401impl StdError for ConfigError {}
402
403#[derive(Debug, Default, Deserialize)]
408#[serde(deny_unknown_fields)]
409struct Schema {
410 #[serde(default)]
411 lint: LintSchema,
412 #[serde(default)]
413 fmt: FmtSchema,
414 #[serde(default)]
415 parse: ParseSchema,
416 #[serde(default)]
417 render: RenderSchema,
418}
419
420#[derive(Debug)]
421struct LintSchema {
422 preset: LintPresetSchema,
423 select: Vec<String>,
424 extend_select: Vec<String>,
425 ignore: Vec<String>,
426 exclude: Vec<String>,
427 info_strings: InfoStringsSchema,
428 render: RenderLintSchema,
429}
430
431impl Default for LintSchema {
432 fn default() -> Self {
433 Self {
434 preset: LintPresetSchema::Default,
435 select: Vec::new(),
436 extend_select: Vec::new(),
437 ignore: Vec::new(),
438 exclude: Vec::new(),
439 info_strings: InfoStringsSchema::default(),
440 render: RenderLintSchema::default(),
441 }
442 }
443}
444
445impl<'de> Deserialize<'de> for LintSchema {
446 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447 where
448 D: Deserializer<'de>,
449 {
450 #[derive(Deserialize)]
451 #[serde(deny_unknown_fields)]
452 struct RawLintSchema {
453 #[serde(default, deserialize_with = "reject_legacy_rules")]
454 rules: (),
455 #[serde(default)]
456 preset: LintPresetSchema,
457 #[serde(default)]
458 select: Vec<String>,
459 #[serde(default, rename = "extend-select")]
460 extend_select: Vec<String>,
461 #[serde(default)]
462 ignore: Vec<String>,
463 #[serde(default)]
464 exclude: Vec<String>,
465 #[serde(default, rename = "info-strings")]
466 info_strings: InfoStringsSchema,
467 #[serde(default)]
468 render: RenderLintSchema,
469 }
470
471 let RawLintSchema {
472 rules: _rules,
473 preset,
474 select,
475 extend_select,
476 ignore,
477 exclude,
478 info_strings,
479 render,
480 } = RawLintSchema::deserialize(deserializer)?;
481
482 for (key, names) in [
483 ("select", select.as_slice()),
484 ("extend-select", extend_select.as_slice()),
485 ("ignore", ignore.as_slice()),
486 ] {
487 for name in names {
488 if matches!(name.as_str(), "default" | "all" | "none") {
489 return Err(D::Error::custom(format!(
490 "`lint.{key}` accepts rule names only; `{name}` is a preset, so use `lint.preset = \"{name}\"`"
491 )));
492 }
493 }
494 }
495
496 if preset != LintPresetSchema::None && !select.is_empty() {
497 return Err(D::Error::custom(
498 "`lint.select` can only be used with `lint.preset = \"none\"`; use `extend-select` to add rules to a preset",
499 ));
500 }
501
502 Ok(Self {
503 preset,
504 select,
505 extend_select,
506 ignore,
507 exclude,
508 info_strings,
509 render,
510 })
511 }
512}
513
514#[derive(Debug, Default, Deserialize)]
515#[serde(deny_unknown_fields)]
516struct RenderLintSchema {
517 #[serde(default)]
518 renderer: RendererSchema,
519 #[serde(default)]
520 packages: Vec<String>,
521 #[serde(default)]
522 macros: HashMap<String, RenderMacroSchema>,
523}
524
525#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
526#[serde(rename_all = "kebab-case")]
527enum RendererSchema {
528 #[default]
529 #[serde(alias = "mathjax-v3")]
530 MathjaxV3,
531 Katex,
532}
533
534impl From<RendererSchema> for Renderer {
535 fn from(s: RendererSchema) -> Self {
536 match s {
537 RendererSchema::MathjaxV3 => Self::MathJaxV3,
538 RendererSchema::Katex => Self::Katex,
539 }
540 }
541}
542
543#[derive(Debug, Deserialize)]
544#[serde(untagged)]
545enum RenderMacroSchema {
546 Arity(u8),
548 Table(RenderMacroTable),
551}
552
553#[derive(Debug, Deserialize)]
554#[serde(deny_unknown_fields)]
555struct RenderMacroTable {
556 #[serde(default)]
557 arity: u8,
558}
559
560impl From<RenderMacroSchema> for u8 {
561 fn from(schema: RenderMacroSchema) -> Self {
562 match schema {
563 RenderMacroSchema::Arity(arity) => arity,
564 RenderMacroSchema::Table(table) => table.arity,
565 }
566 }
567}
568
569impl From<RenderLintSchema> for LintRenderOptions {
570 fn from(schema: RenderLintSchema) -> Self {
571 Self {
572 renderer: schema.renderer.into(),
573 packages: schema.packages,
574 macros: schema.macros.into_iter().map(|(name, m)| (name, m.into())).collect(),
575 }
576 }
577}
578
579fn reject_legacy_rules<'de, D>(deserializer: D) -> Result<(), D::Error>
580where
581 D: Deserializer<'de>,
582{
583 let _ignored = toml::Value::deserialize(deserializer)?;
584 Err(D::Error::custom(
585 "`lint.rules` has been replaced by `lint.preset`, `lint.select`, `lint.extend-select`, and `lint.ignore`",
586 ))
587}
588
589#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
590#[serde(rename_all = "kebab-case")]
591enum LintPresetSchema {
592 #[default]
593 Default,
594 All,
595 None,
596}
597
598impl From<LintPresetSchema> for LintRulePreset {
599 fn from(s: LintPresetSchema) -> Self {
600 match s {
601 LintPresetSchema::Default => Self::Default,
602 LintPresetSchema::All => Self::All,
603 LintPresetSchema::None => Self::None,
604 }
605 }
606}
607
608#[derive(Debug, Default, Deserialize)]
609#[serde(deny_unknown_fields)]
610struct InfoStringsSchema {
611 #[serde(default)]
612 extra: Vec<String>,
613}
614
615#[derive(Debug, Default, Deserialize)]
616#[serde(deny_unknown_fields)]
617struct FmtSchema {
618 #[serde(default)]
619 profile: Option<FmtProfileSchema>,
620 #[serde(default)]
621 wrap: Option<WrapSchema>,
622 #[serde(default, rename = "wrap-strategy")]
623 wrap_strategy: Option<WrapStrategySchema>,
624 #[serde(default)]
625 italic: Option<ItalicSchema>,
626 #[serde(default)]
627 strong: Option<StrongSchema>,
628 #[serde(default, rename = "list-marker")]
629 list_marker: Option<ListMarkerSchema>,
630 #[serde(default, rename = "ordered-list")]
631 ordered_list: Option<OrderedListSchema>,
632 #[serde(default, rename = "thematic-break")]
633 thematic_break: Option<ThematicSchema>,
634 #[serde(default, rename = "trailing-newline")]
635 trailing_newline: Option<TrailingNewlineSchema>,
636 #[serde(default, rename = "end-of-line")]
637 end_of_line: Option<EndOfLineSchema>,
638 #[serde(default)]
639 exclude: Vec<String>,
640 #[serde(default)]
641 refs: Option<RefsSchema>,
642 #[serde(default)]
643 footnotes: Option<FootnotesSchema>,
644 #[serde(default)]
645 tables: Option<TablesSchema>,
646 #[serde(default)]
647 lists: Option<ListsSchema>,
648 #[serde(default)]
649 frontmatter: Option<FrontmatterSchema>,
650 #[serde(default)]
651 math: Option<MathSchema>,
652 #[serde(default, rename = "heading-attrs")]
653 heading_attrs: Option<HeadingAttrsSchema>,
654}
655
656fn fmt_options_from_schema(schema: FmtSchema) -> FmtOptions {
657 let refs = schema.refs.unwrap_or_default();
658 let footnotes = schema.footnotes.unwrap_or_default();
659 let tables = schema.tables.unwrap_or_default();
660 let lists = schema.lists.unwrap_or_default();
661 let frontmatter = schema.frontmatter.unwrap_or_default();
662 let default = match schema.profile.unwrap_or(FmtProfileSchema::Preserve) {
663 FmtProfileSchema::Preserve => FmtOptions::default(),
664 FmtProfileSchema::Mdformat => FmtOptions::mdformat(),
665 };
666 let mut opts = default
667 .clone()
668 .with_exclude_globs(schema.exclude)
669 .with_link_def_placement(
670 refs.placement
671 .map_or_else(|| default.link_def_placement(), Placement::from),
672 )
673 .with_link_def_style(refs.style.map_or_else(|| default.link_def_style(), LinkDefStyle::from))
674 .with_footnote_placement(
675 footnotes
676 .placement
677 .map_or_else(|| default.footnote_placement(), Placement::from),
678 );
679 opts = opts.with_preserve_frontmatter(frontmatter.preserve.unwrap_or_else(|| default.preserve_frontmatter()));
680 opts = opts.with_table(tables.style.map_or_else(|| default.table(), TableStyle::from));
681 opts = opts.with_list_continuation_indent(
682 lists
683 .continuation_indent
684 .map_or_else(|| default.list_continuation_indent(), ListContinuationIndent::from),
685 );
686 if let Some(wrap) = schema.wrap {
687 opts = opts.with_wrap(Wrap::from(wrap));
688 }
689 if let Some(strategy) = schema.wrap_strategy {
690 opts = opts.with_wrap_strategy(WrapStrategy::from(strategy));
691 }
692 if let Some(italic) = schema.italic {
693 opts = opts.with_italic(ItalicStyle::from(italic));
694 }
695 if let Some(strong) = schema.strong {
696 opts = opts.with_strong(StrongStyle::from(strong));
697 }
698 if let Some(list_marker) = schema.list_marker {
699 opts = opts.with_list_marker(ListMarkerStyle::from(list_marker));
700 }
701 if let Some(ordered_list) = schema.ordered_list {
702 opts = opts.with_ordered_list(OrderedListStyle::from(ordered_list));
703 }
704 if let Some(thematic_break) = schema.thematic_break {
705 opts = opts.with_thematic_break(ThematicStyle::from(thematic_break));
706 }
707 if let Some(trailing_newline) = schema.trailing_newline {
708 opts = opts.with_trailing_newline(TrailingNewline::from(trailing_newline));
709 }
710 if let Some(end_of_line) = schema.end_of_line {
711 opts = opts.with_end_of_line(EndOfLine::from(end_of_line));
712 }
713 if let Some(math) = schema.math {
714 opts = opts.with_math(MathOptions::from(math));
715 }
716 if let Some(heading_attrs) = schema.heading_attrs {
717 opts = opts.with_heading_attrs(HeadingAttrsStyle::from(heading_attrs));
718 }
719 opts
720}
721
722#[derive(Debug, Default, Deserialize)]
723#[serde(deny_unknown_fields)]
724struct ParseSchema {
725 #[serde(default)]
726 extensions: Option<ExtensionsSchema>,
727 #[serde(default)]
728 math: Option<ParseMathSchema>,
729}
730
731fn parse_options_from_schema(schema: ParseSchema) -> ParseOptions {
732 let mut opts = ParseOptions::default();
733 if let Some(extensions) = schema.extensions {
734 opts = opts.with_extensions(ExtensionOptions::from(extensions));
735 }
736 if let Some(math) = schema.math {
737 opts = opts.with_math(MathParseOptions::from(math));
738 }
739 opts
740}
741
742#[derive(Debug, Default, Deserialize)]
743#[serde(deny_unknown_fields)]
744struct RenderSchema {
745 #[serde(default)]
746 profile: Option<RenderProfileSchema>,
747}
748
749fn render_options_from_schema(schema: RenderSchema) -> RenderOptions {
750 let default = RenderOptions::default();
751 RenderOptions::default().with_profile(schema.profile.map_or_else(|| default.profile(), RenderProfile::from))
752}
753
754#[derive(Debug, Deserialize)]
755#[serde(rename_all = "kebab-case")]
756enum RenderProfileSchema {
757 Pulldown,
758 CmarkGfm,
759}
760
761impl From<RenderProfileSchema> for RenderProfile {
762 fn from(s: RenderProfileSchema) -> Self {
763 match s {
764 RenderProfileSchema::Pulldown => Self::Pulldown,
765 RenderProfileSchema::CmarkGfm => Self::CmarkGfm,
766 }
767 }
768}
769
770#[derive(Debug, Deserialize)]
771#[serde(rename_all = "kebab-case")]
772enum HeadingAttrsSchema {
773 Preserve,
774 Canonicalise,
775}
776
777impl From<HeadingAttrsSchema> for HeadingAttrsStyle {
778 fn from(s: HeadingAttrsSchema) -> Self {
779 match s {
780 HeadingAttrsSchema::Preserve => Self::Preserve,
781 HeadingAttrsSchema::Canonicalise => Self::Canonicalise,
782 }
783 }
784}
785
786#[derive(Debug, Default, Deserialize)]
787#[serde(deny_unknown_fields)]
788#[allow(
789 clippy::struct_field_names,
790 clippy::struct_excessive_bools,
791 reason = "shape mirrors `ExtensionOptions`; the `_lists` postfix matches the TOML key convention"
792)]
793struct ExtensionsSchema {
794 #[serde(default)]
795 gfm: Option<GfmSchema>,
796 #[serde(default, rename = "definition-lists")]
797 definition_lists: Option<bool>,
798 #[serde(default, rename = "abbreviation-lists")]
799 abbreviation_lists: Option<bool>,
800 #[serde(default, rename = "heading-attribute-lists")]
801 heading_attribute_lists: Option<bool>,
802 #[serde(default, rename = "block-attribute-lists")]
803 block_attribute_lists: Option<bool>,
804 #[serde(default)]
805 myst: Option<MystSchema>,
806 #[serde(default)]
807 pandoc: Option<PandocSchema>,
808}
809
810impl From<ExtensionsSchema> for ExtensionOptions {
811 fn from(s: ExtensionsSchema) -> Self {
812 let default = Self::default();
813 Self {
814 gfm: s.gfm.map_or(default.gfm, GfmOptions::from),
815 definition_lists: s.definition_lists.unwrap_or(default.definition_lists),
816 abbreviation_lists: s.abbreviation_lists.unwrap_or(default.abbreviation_lists),
817 heading_attribute_lists: s.heading_attribute_lists.unwrap_or(default.heading_attribute_lists),
818 block_attribute_lists: s.block_attribute_lists.unwrap_or(default.block_attribute_lists),
819 myst: s.myst.map_or(default.myst, MystOptions::from),
820 pandoc: s.pandoc.map_or(default.pandoc, PandocOptions::from),
821 }
822 }
823}
824
825#[derive(Debug, Default, Deserialize)]
826#[serde(deny_unknown_fields)]
827struct GfmSchema {
828 #[serde(default)]
829 autolinks: Option<GfmAutolinkPolicySchema>,
830 #[serde(default)]
831 tagfilter: Option<bool>,
832}
833
834impl From<GfmSchema> for GfmOptions {
835 fn from(s: GfmSchema) -> Self {
836 let default = Self::default();
837 Self {
838 autolinks: s.autolinks.map_or(default.autolinks, GfmAutolinkPolicy::from),
839 tagfilter: s.tagfilter.unwrap_or(default.tagfilter),
840 }
841 }
842}
843
844#[derive(Copy, Clone, Debug, Deserialize)]
845#[serde(rename_all = "kebab-case")]
846enum GfmAutolinkPolicySchema {
847 Disabled,
848 Urls,
849 UrlsAndEmails,
850}
851
852impl From<GfmAutolinkPolicySchema> for GfmAutolinkPolicy {
853 fn from(s: GfmAutolinkPolicySchema) -> Self {
854 match s {
855 GfmAutolinkPolicySchema::Disabled => Self::Disabled,
856 GfmAutolinkPolicySchema::Urls => Self::Urls,
857 GfmAutolinkPolicySchema::UrlsAndEmails => Self::UrlsAndEmails,
858 }
859 }
860}
861
862#[derive(Debug, Default, Deserialize)]
863#[serde(deny_unknown_fields)]
864struct ParseMathSchema {
865 #[serde(default)]
866 delimiters: Option<MathDelimiterSetSchema>,
867}
868
869impl From<ParseMathSchema> for MathParseOptions {
870 fn from(s: ParseMathSchema) -> Self {
871 let default = Self::default();
872 Self {
873 delimiters: s.delimiters.map_or(default.delimiters, MathDelimiterSet::from),
874 }
875 }
876}
877
878#[derive(Copy, Clone, Debug, Deserialize)]
879#[serde(rename_all = "kebab-case")]
880enum MathDelimiterSetSchema {
881 Tex,
882 Github,
883}
884
885impl From<MathDelimiterSetSchema> for MathDelimiterSet {
886 fn from(s: MathDelimiterSetSchema) -> Self {
887 match s {
888 MathDelimiterSetSchema::Tex => Self::Tex,
889 MathDelimiterSetSchema::Github => Self::Github,
890 }
891 }
892}
893
894#[derive(Debug, Default, Deserialize)]
895#[serde(deny_unknown_fields)]
896#[allow(clippy::struct_excessive_bools, reason = "shape mirrors `MystOptions`")]
897struct MystSchema {
898 #[serde(default, rename = "directive-containers")]
899 directive_containers: Option<bool>,
900 #[serde(default, rename = "inline-roles")]
901 inline_roles: Option<bool>,
902 #[serde(default, rename = "substitution-references")]
903 substitution_references: Option<bool>,
904 #[serde(default)]
905 comments: Option<bool>,
906}
907
908impl From<MystSchema> for MystOptions {
909 fn from(s: MystSchema) -> Self {
910 let default = Self::default();
911 Self {
912 directive_containers: s.directive_containers.unwrap_or(default.directive_containers),
913 inline_roles: s.inline_roles.unwrap_or(default.inline_roles),
914 substitution_references: s.substitution_references.unwrap_or(default.substitution_references),
915 comments: s.comments.unwrap_or(default.comments),
916 }
917 }
918}
919
920#[derive(Debug, Default, Deserialize)]
921#[serde(deny_unknown_fields)]
922struct PandocSchema {
923 #[serde(default, rename = "fenced-divs")]
924 fenced_divs: Option<bool>,
925 #[serde(default, rename = "short-form-divs")]
926 short_form_divs: Option<bool>,
927 #[serde(default, rename = "inline-attribute-spans")]
928 inline_attribute_spans: Option<bool>,
929}
930
931impl From<PandocSchema> for PandocOptions {
932 fn from(s: PandocSchema) -> Self {
933 let default = Self::default();
934 Self {
935 fenced_divs: s.fenced_divs.unwrap_or(default.fenced_divs),
936 short_form_divs: s.short_form_divs.unwrap_or(default.short_form_divs),
937 inline_attribute_spans: s.inline_attribute_spans.unwrap_or(default.inline_attribute_spans),
938 }
939 }
940}
941
942#[derive(Debug, Default, Deserialize)]
943#[serde(deny_unknown_fields)]
944struct MathSchema {
945 #[serde(default)]
946 normalise: Option<bool>,
947 #[serde(default)]
948 render: Option<MathRenderSchema>,
949}
950
951#[derive(Debug, Deserialize)]
952#[serde(rename_all = "kebab-case")]
953enum MathRenderSchema {
954 None,
955 CommonmarkKatex,
956 Dollar,
957}
958
959impl From<MathRenderSchema> for MathRender {
960 fn from(s: MathRenderSchema) -> Self {
961 match s {
962 MathRenderSchema::None => Self::None,
963 MathRenderSchema::CommonmarkKatex => Self::CommonmarkKatex,
964 MathRenderSchema::Dollar => Self::Dollar,
965 }
966 }
967}
968
969impl From<MathSchema> for MathOptions {
970 fn from(s: MathSchema) -> Self {
971 let default = Self::default();
972 Self {
973 normalise: s.normalise.unwrap_or(default.normalise),
974 render: s.render.map_or(default.render, MathRender::from),
975 }
976 }
977}
978
979#[derive(Debug, Default, Deserialize)]
980#[serde(deny_unknown_fields)]
981struct FrontmatterSchema {
982 #[serde(default)]
983 preserve: Option<bool>,
984}
985
986#[derive(Debug, Default, Deserialize)]
987#[serde(deny_unknown_fields)]
988struct RefsSchema {
989 #[serde(default)]
990 placement: Option<PlacementSchema>,
991 #[serde(default)]
992 style: Option<LinkDefStyleSchema>,
993}
994
995#[derive(Debug, Default, Deserialize)]
996#[serde(deny_unknown_fields)]
997struct FootnotesSchema {
998 #[serde(default)]
999 placement: Option<PlacementSchema>,
1000}
1001
1002#[derive(Debug, Default, Deserialize)]
1003#[serde(deny_unknown_fields)]
1004struct TablesSchema {
1005 #[serde(default)]
1006 style: Option<TableStyleSchema>,
1007}
1008
1009#[derive(Debug, Default, Deserialize)]
1010#[serde(deny_unknown_fields)]
1011struct ListsSchema {
1012 #[serde(default, rename = "continuation-indent")]
1013 continuation_indent: Option<ListContinuationIndentSchema>,
1014}
1015
1016#[derive(Debug, Deserialize)]
1017#[serde(rename_all = "kebab-case")]
1018enum ListContinuationIndentSchema {
1019 MarkerWidth,
1020 FourSpace,
1021}
1022
1023impl From<ListContinuationIndentSchema> for ListContinuationIndent {
1024 fn from(s: ListContinuationIndentSchema) -> Self {
1025 match s {
1026 ListContinuationIndentSchema::MarkerWidth => Self::MarkerWidth,
1027 ListContinuationIndentSchema::FourSpace => Self::FourSpace,
1028 }
1029 }
1030}
1031
1032#[derive(Debug, Deserialize)]
1033#[serde(rename_all = "lowercase")]
1034enum PlacementSchema {
1035 End,
1036 Preserve,
1037}
1038
1039#[derive(Debug, Deserialize)]
1040#[serde(rename_all = "lowercase")]
1041enum LinkDefStyleSchema {
1042 Bare,
1043 Angle,
1044 Preserve,
1045}
1046
1047#[derive(Debug)]
1048enum WrapSchema {
1049 Mode(WrapMode),
1050 Columns(u32),
1051}
1052
1053impl<'de> Deserialize<'de> for WrapSchema {
1054 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1055 where
1056 D: Deserializer<'de>,
1057 {
1058 struct WrapVisitor;
1059
1060 impl Visitor<'_> for WrapVisitor {
1061 type Value = WrapSchema;
1062
1063 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064 formatter.write_str(r#""keep", "no", or an integer column width"#)
1065 }
1066
1067 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1068 where
1069 E: DeError,
1070 {
1071 match value {
1072 "keep" => Ok(WrapSchema::Mode(WrapMode::Keep)),
1073 "no" => Ok(WrapSchema::Mode(WrapMode::No)),
1074 _ => Err(E::custom(format!(
1075 r#"invalid wrap value {value:?}; expected "keep", "no", or an integer column width"#
1076 ))),
1077 }
1078 }
1079
1080 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
1081 where
1082 E: DeError,
1083 {
1084 let columns = u32::try_from(value).map_err(|_| {
1085 E::custom(format!(
1086 "wrap column width {value} is too large; expected an integer from 0 to {}",
1087 u32::MAX
1088 ))
1089 })?;
1090 Ok(WrapSchema::Columns(columns))
1091 }
1092
1093 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
1094 where
1095 E: DeError,
1096 {
1097 let columns = u32::try_from(value).map_err(|_| {
1098 E::custom(format!(
1099 r#"invalid wrap value {value}; expected "keep", "no", or a non-negative integer column width"#
1100 ))
1101 })?;
1102 Ok(WrapSchema::Columns(columns))
1103 }
1104 }
1105
1106 deserializer.deserialize_any(WrapVisitor)
1107 }
1108}
1109
1110#[derive(Debug, Deserialize)]
1111#[serde(rename_all = "lowercase")]
1112enum WrapMode {
1113 Keep,
1114 No,
1115}
1116
1117#[derive(Debug, Deserialize)]
1118#[serde(rename_all = "kebab-case")]
1119enum WrapStrategySchema {
1120 Stable,
1121 Balanced,
1122}
1123
1124impl From<WrapStrategySchema> for WrapStrategy {
1125 fn from(s: WrapStrategySchema) -> Self {
1126 match s {
1127 WrapStrategySchema::Stable => Self::Stable,
1128 WrapStrategySchema::Balanced => Self::Balanced,
1129 }
1130 }
1131}
1132
1133#[derive(Debug, Deserialize)]
1134#[serde(rename_all = "lowercase")]
1135enum ItalicSchema {
1136 Asterisk,
1137 Underscore,
1138 Preserve,
1139}
1140
1141#[derive(Debug, Deserialize)]
1142#[serde(rename_all = "lowercase")]
1143enum StrongSchema {
1144 Asterisk,
1145 Underscore,
1146 Preserve,
1147}
1148
1149#[derive(Debug, Deserialize)]
1150#[serde(rename_all = "kebab-case")]
1151enum FmtProfileSchema {
1152 Preserve,
1153 Mdformat,
1154}
1155
1156#[derive(Debug, Deserialize)]
1157#[serde(rename_all = "lowercase")]
1158enum ListMarkerSchema {
1159 Dash,
1160 Asterisk,
1161 Plus,
1162 Preserve,
1163}
1164
1165#[derive(Debug, Deserialize)]
1166#[serde(rename_all = "lowercase")]
1167enum OrderedListSchema {
1168 One,
1169 Consistent,
1170 Preserve,
1171}
1172
1173#[derive(Debug, Deserialize)]
1174#[serde(rename_all = "kebab-case")]
1175enum ThematicSchema {
1176 Dash,
1177 Asterisk,
1178 Underscore,
1179 #[serde(rename = "underscore-70")]
1180 Underscore70,
1181 Preserve,
1182}
1183
1184#[derive(Debug, Deserialize)]
1185#[serde(rename_all = "lowercase")]
1186enum TableStyleSchema {
1187 Compact,
1188 Align,
1189 Preserve,
1190}
1191
1192#[derive(Debug, Deserialize)]
1193#[serde(untagged)]
1194enum TrailingNewlineSchema {
1195 Named(TrailingNewlineNamed),
1196 Bool(bool),
1200}
1201
1202#[derive(Debug, Deserialize)]
1203#[serde(rename_all = "lowercase")]
1204enum TrailingNewlineNamed {
1205 Preserve,
1206 Strip,
1207 Ensure,
1208}
1209
1210impl From<TrailingNewlineSchema> for TrailingNewline {
1211 fn from(s: TrailingNewlineSchema) -> Self {
1212 match s {
1213 TrailingNewlineSchema::Named(TrailingNewlineNamed::Preserve) => Self::Preserve,
1214 TrailingNewlineSchema::Named(TrailingNewlineNamed::Strip) => Self::Strip,
1215 TrailingNewlineSchema::Named(TrailingNewlineNamed::Ensure) => Self::Ensure,
1216 TrailingNewlineSchema::Bool(true) => Self::Ensure,
1217 TrailingNewlineSchema::Bool(false) => Self::Strip,
1218 }
1219 }
1220}
1221
1222#[derive(Debug, Deserialize)]
1223#[serde(rename_all = "lowercase")]
1224enum EndOfLineSchema {
1225 Lf,
1226 Crlf,
1227 Keep,
1228}
1229
1230impl From<WrapSchema> for Wrap {
1231 fn from(s: WrapSchema) -> Self {
1232 match s {
1233 WrapSchema::Mode(WrapMode::Keep) => Self::Keep,
1234 WrapSchema::Mode(WrapMode::No) => Self::No,
1235 WrapSchema::Columns(n) => Self::At(n),
1236 }
1237 }
1238}
1239
1240impl From<ItalicSchema> for ItalicStyle {
1241 fn from(s: ItalicSchema) -> Self {
1242 match s {
1243 ItalicSchema::Asterisk => Self::Asterisk,
1244 ItalicSchema::Underscore => Self::Underscore,
1245 ItalicSchema::Preserve => Self::Preserve,
1246 }
1247 }
1248}
1249
1250impl From<StrongSchema> for StrongStyle {
1251 fn from(s: StrongSchema) -> Self {
1252 match s {
1253 StrongSchema::Asterisk => Self::Asterisk,
1254 StrongSchema::Underscore => Self::Underscore,
1255 StrongSchema::Preserve => Self::Preserve,
1256 }
1257 }
1258}
1259
1260impl From<ThematicSchema> for ThematicStyle {
1261 fn from(s: ThematicSchema) -> Self {
1262 match s {
1263 ThematicSchema::Dash => Self::Dash,
1264 ThematicSchema::Asterisk => Self::Asterisk,
1265 ThematicSchema::Underscore => Self::Underscore,
1266 ThematicSchema::Underscore70 => Self::Underscore70,
1267 ThematicSchema::Preserve => Self::Preserve,
1268 }
1269 }
1270}
1271
1272impl From<TableStyleSchema> for TableStyle {
1273 fn from(s: TableStyleSchema) -> Self {
1274 match s {
1275 TableStyleSchema::Compact => Self::Compact,
1276 TableStyleSchema::Align => Self::Align,
1277 TableStyleSchema::Preserve => Self::Preserve,
1278 }
1279 }
1280}
1281
1282impl From<ListMarkerSchema> for ListMarkerStyle {
1283 fn from(s: ListMarkerSchema) -> Self {
1284 match s {
1285 ListMarkerSchema::Dash => Self::Dash,
1286 ListMarkerSchema::Asterisk => Self::Asterisk,
1287 ListMarkerSchema::Plus => Self::Plus,
1288 ListMarkerSchema::Preserve => Self::Preserve,
1289 }
1290 }
1291}
1292
1293impl From<OrderedListSchema> for OrderedListStyle {
1294 fn from(s: OrderedListSchema) -> Self {
1295 match s {
1296 OrderedListSchema::One => Self::One,
1297 OrderedListSchema::Consistent => Self::Consistent,
1298 OrderedListSchema::Preserve => Self::Preserve,
1299 }
1300 }
1301}
1302
1303impl From<PlacementSchema> for Placement {
1304 fn from(s: PlacementSchema) -> Self {
1305 match s {
1306 PlacementSchema::End => Self::End,
1307 PlacementSchema::Preserve => Self::Preserve,
1308 }
1309 }
1310}
1311
1312impl From<LinkDefStyleSchema> for LinkDefStyle {
1313 fn from(s: LinkDefStyleSchema) -> Self {
1314 match s {
1315 LinkDefStyleSchema::Bare => Self::Bare,
1316 LinkDefStyleSchema::Angle => Self::Angle,
1317 LinkDefStyleSchema::Preserve => Self::Preserve,
1318 }
1319 }
1320}
1321
1322impl From<EndOfLineSchema> for EndOfLine {
1323 fn from(s: EndOfLineSchema) -> Self {
1324 match s {
1325 EndOfLineSchema::Lf => Self::Lf,
1326 EndOfLineSchema::Crlf => Self::Crlf,
1327 EndOfLineSchema::Keep => Self::Keep,
1328 }
1329 }
1330}
1331
1332fn read_mdwright_toml(path: &Path) -> Result<Config, ConfigError> {
1337 let text = fs::read_to_string(path).map_err(|e| ConfigError::io(path, &e))?;
1338 let schema: Schema = toml::from_str(&text).map_err(|e| ConfigError::parse(path, &e))?;
1339 Ok(Config::from_schema(schema, Some(path.to_owned())))
1340}
1341
1342fn discover_walk(start: &Path) -> Result<Option<Config>, ConfigError> {
1346 for dir in start.ancestors() {
1347 if let Some(cfg) = try_load_dir(dir)? {
1348 return Ok(Some(cfg));
1349 }
1350 if dir.join(".git").exists() {
1351 return Ok(None);
1352 }
1353 }
1354 Ok(None)
1355}
1356
1357fn try_load_dir(dir: &Path) -> Result<Option<Config>, ConfigError> {
1362 for name in [".mdwright.toml", "mdwright.toml"] {
1363 let candidate = dir.join(name);
1364 if candidate.is_file() {
1365 return Ok(Some(read_mdwright_toml(&candidate)?));
1366 }
1367 }
1368 let pyproject = dir.join("pyproject.toml");
1369 if pyproject.is_file() {
1370 return read_pyproject(&pyproject);
1371 }
1372 Ok(None)
1373}
1374
1375fn read_pyproject(path: &Path) -> Result<Option<Config>, ConfigError> {
1376 let text = fs::read_to_string(path).map_err(|e| ConfigError::io(path, &e))?;
1377 let value: toml::Value = toml::from_str(&text).map_err(|e| ConfigError::parse(path, &e))?;
1378 let Some(table) = value.as_table() else {
1379 return Ok(None);
1380 };
1381 let Some(tool) = table.get("tool").and_then(toml::Value::as_table) else {
1382 return Ok(None);
1383 };
1384 let Some(mdw) = tool.get("mdwright") else {
1385 return Ok(None);
1386 };
1387 let schema: Schema = mdw
1388 .clone()
1389 .try_into()
1390 .map_err(|e: toml::de::Error| ConfigError::parse(path, &e))?;
1391 Ok(Some(Config::from_schema(schema, Some(path.to_owned()))))
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396 use anyhow::{Result, anyhow};
1397
1398 use mdwright_lint::RuleSet;
1399
1400 use crate::documentation;
1401
1402 use super::{
1403 Config, EndOfLine, FmtOptions, GfmAutolinkPolicy, ItalicStyle, LintRulePreset, ListContinuationIndent,
1404 ListMarkerStyle, MathDelimiterSet, MathRender, OrderedListStyle, RenderProfile, Schema, StrongStyle,
1405 TableStyle, ThematicStyle, TrailingNewline, Wrap, WrapStrategy,
1406 };
1407
1408 fn schema_from_str(src: &str) -> Result<Schema> {
1409 toml::from_str::<Schema>(src).map_err(|e| anyhow!("parse: {e}"))
1410 }
1411
1412 fn config_from_str(src: &str) -> Result<Config> {
1413 Ok(Config::from_schema(schema_from_str(src)?, None))
1414 }
1415
1416 #[test]
1417 fn parses_complete_toml() -> Result<()> {
1418 let src = r#"
1419[lint]
1420preset = "default"
1421extend-select = ["escaped-emphasis"]
1422ignore = ["bare-url"]
1423exclude = ["docs/vendored/**"]
1424[lint.info-strings]
1425extra = ["promql"]
1426
1427[fmt]
1428wrap = 70
1429italic = "asterisk"
1430strong = "underscore"
1431list-marker = "dash"
1432ordered-list = "consistent"
1433thematic-break = "asterisk"
1434trailing-newline = true
1435end-of-line = "lf"
1436exclude = ["docs/generated/**"]
1437
1438[fmt.tables]
1439style = "align"
1440"#;
1441 let cfg = config_from_str(src)?;
1442 let lint = cfg.lint_rule_selection();
1443 assert_eq!(lint.preset(), LintRulePreset::Default);
1444 assert!(lint.select().is_empty());
1445 assert_eq!(lint.extend_select(), &["escaped-emphasis".to_owned()]);
1446 assert_eq!(lint.ignore(), &["bare-url".to_owned()]);
1447 assert_eq!(cfg.exclude_globs(), &["docs/vendored/**".to_owned()]);
1448 assert_eq!(cfg.extra_info_strings(), &["promql".to_owned()]);
1449 let fmt = cfg.fmt_options();
1450 assert_eq!(fmt.wrap(), Wrap::At(70));
1451 assert_eq!(fmt.wrap_strategy(), WrapStrategy::Stable);
1452 assert_eq!(fmt.italic(), ItalicStyle::Asterisk);
1453 assert_eq!(fmt.strong(), StrongStyle::Underscore);
1454 assert_eq!(fmt.list_marker(), ListMarkerStyle::Dash);
1455 assert_eq!(fmt.ordered_list(), OrderedListStyle::Consistent);
1456 assert_eq!(fmt.thematic_break_style(), ThematicStyle::Asterisk);
1457 assert_eq!(fmt.table(), TableStyle::Align);
1458 assert_eq!(fmt.trailing_newline(), TrailingNewline::Ensure);
1459 assert_eq!(fmt.end_of_line(), EndOfLine::Lf);
1460 assert_eq!(fmt.exclude_globs(), &["docs/generated/**".to_owned()]);
1461 Ok(())
1462 }
1463
1464 #[test]
1465 fn default_lint_selection_resolves_defaults() -> Result<()> {
1466 let cfg = config_from_str("")?;
1467 let rules = cfg
1468 .lint_rule_selection()
1469 .resolve(RuleSet::stdlib_all())
1470 .map_err(|err| anyhow!("{err}"))?;
1471 assert!(!rules.is_empty());
1472 assert!(rules.contains("bare-url"));
1473 assert!(!rules.contains("latex-command"));
1474 Ok(())
1475 }
1476
1477 #[test]
1478 fn lint_selection_supports_all_preset() -> Result<()> {
1479 let cfg = config_from_str("[lint]\npreset = \"all\"\n")?;
1480 let rules = cfg
1481 .lint_rule_selection()
1482 .resolve(RuleSet::stdlib_all())
1483 .map_err(|err| anyhow!("{err}"))?;
1484 assert!(rules.contains("latex-command"));
1485 assert!(rules.contains("bare-url"));
1486 Ok(())
1487 }
1488
1489 #[test]
1490 fn lint_selection_supports_explicit_select_with_none_preset() -> Result<()> {
1491 let cfg = config_from_str("[lint]\npreset = \"none\"\nselect = [\"heading-punctuation\", \"bare-url\"]\n")?;
1492 let rules = cfg
1493 .lint_rule_selection()
1494 .resolve(RuleSet::stdlib_all())
1495 .map_err(|err| anyhow!("{err}"))?;
1496 assert!(rules.contains("heading-punctuation"));
1497 assert!(rules.contains("bare-url"));
1498 assert_eq!(rules.len(), 2);
1499 Ok(())
1500 }
1501
1502 #[test]
1503 fn lint_selection_supports_extend_select_and_ignore() -> Result<()> {
1504 let cfg = config_from_str(
1505 "[lint]\npreset = \"default\"\nextend-select = [\"latex-command\"]\nignore = [\"bare-url\"]\n",
1506 )?;
1507 let rules = cfg
1508 .lint_rule_selection()
1509 .resolve(RuleSet::stdlib_all())
1510 .map_err(|err| anyhow!("{err}"))?;
1511 assert!(rules.contains("latex-command"));
1512 assert!(!rules.contains("bare-url"));
1513 Ok(())
1514 }
1515
1516 #[test]
1517 fn rejects_legacy_rules_key_with_migration_hint() -> Result<()> {
1518 let err = toml::from_str::<Schema>("[lint]\nrules = \"default,+latex-command\"\n")
1519 .err()
1520 .ok_or_else(|| anyhow!("expected error"))?;
1521 let rendered = err.to_string();
1522 assert!(
1523 rendered.contains("lint.rules"),
1524 "error should name legacy key: {rendered}"
1525 );
1526 assert!(
1527 rendered.contains("extend-select"),
1528 "error should suggest new keys: {rendered}"
1529 );
1530 Ok(())
1531 }
1532
1533 #[test]
1534 fn rejects_presets_in_rule_name_lists() -> Result<()> {
1535 let err = toml::from_str::<Schema>("[lint]\npreset = \"none\"\nselect = [\"default\"]\n")
1536 .err()
1537 .ok_or_else(|| anyhow!("expected error"))?;
1538 let rendered = err.to_string();
1539 assert!(
1540 rendered.contains("preset") && rendered.contains("select"),
1541 "error should explain preset/rule split: {rendered}"
1542 );
1543 Ok(())
1544 }
1545
1546 #[test]
1547 fn rejects_select_with_non_none_preset() -> Result<()> {
1548 let err = toml::from_str::<Schema>("[lint]\npreset = \"default\"\nselect = [\"bare-url\"]\n")
1549 .err()
1550 .ok_or_else(|| anyhow!("expected error"))?;
1551 let rendered = err.to_string();
1552 assert!(
1553 rendered.contains("extend-select") && rendered.contains("preset"),
1554 "error should explain valid shape: {rendered}"
1555 );
1556 Ok(())
1557 }
1558
1559 #[test]
1560 fn resolve_rejects_unknown_rule_names() -> Result<()> {
1561 let cfg = config_from_str("[lint]\nextend-select = [\"no-such-rule\"]\n")?;
1562 let err = cfg
1563 .lint_rule_selection()
1564 .resolve(RuleSet::stdlib_all())
1565 .err()
1566 .ok_or_else(|| anyhow!("expected error"))?;
1567 assert!(err.to_string().contains("no-such-rule"));
1568 Ok(())
1569 }
1570
1571 #[test]
1572 fn parses_render_packages_and_macros() -> Result<()> {
1573 let src = r#"
1574[lint.render]
1575renderer = "mathjax-v3"
1576packages = ["mhchem", "physics"]
1577[lint.render.macros]
1578RR = 0
1579NN = { arity = 0 }
1580proj = { arity = 1 }
1581"#;
1582 let cfg = config_from_str(src)?;
1583 let options = cfg.render_lint_options();
1584 assert_eq!(options.renderer(), mdwright_mathrender::Renderer::MathJaxV3);
1585 assert_eq!(options.packages(), &["mhchem".to_owned(), "physics".to_owned()]);
1586 assert_eq!(options.macros().get("RR"), Some(&0));
1587 assert_eq!(options.macros().get("NN"), Some(&0));
1588 assert_eq!(options.macros().get("proj"), Some(&1));
1589 Ok(())
1590 }
1591
1592 #[test]
1593 fn parses_katex_renderer_choice() -> Result<()> {
1594 let src = "[lint.render]\nrenderer = \"katex\"\n";
1595 let cfg = config_from_str(src)?;
1596 assert_eq!(
1597 cfg.render_lint_options().renderer(),
1598 mdwright_mathrender::Renderer::Katex
1599 );
1600 Ok(())
1601 }
1602
1603 #[test]
1604 fn rejects_unknown_render_key() {
1605 let err = toml::from_str::<Schema>("[lint.render]\nfoo = []\n");
1606 assert!(err.is_err(), "unknown key should be rejected");
1607 }
1608
1609 #[test]
1610 fn generated_default_toml_parses_as_defaults() -> Result<()> {
1611 let generated = documentation::render_default_toml();
1612 let cfg = config_from_str(&generated)?;
1613 let default = Config::defaults();
1614
1615 assert_eq!(cfg.lint_rule_selection(), default.lint_rule_selection());
1616 assert_eq!(cfg.exclude_globs(), default.exclude_globs());
1617 assert_eq!(cfg.extra_info_strings(), default.extra_info_strings());
1618 assert_eq!(cfg.parse_options(), default.parse_options());
1619 assert_eq!(cfg.render_options(), default.render_options());
1620
1621 let fmt = cfg.fmt_options();
1622 let default_fmt = default.fmt_options();
1623 assert_eq!(fmt.wrap(), default_fmt.wrap());
1624 assert_eq!(fmt.wrap_strategy(), default_fmt.wrap_strategy());
1625 assert_eq!(fmt.italic(), default_fmt.italic());
1626 assert_eq!(fmt.strong(), default_fmt.strong());
1627 assert_eq!(fmt.list_marker(), default_fmt.list_marker());
1628 assert_eq!(fmt.ordered_list(), default_fmt.ordered_list());
1629 assert_eq!(fmt.thematic_break_style(), default_fmt.thematic_break_style());
1630 assert_eq!(fmt.trailing_newline(), default_fmt.trailing_newline());
1631 assert_eq!(fmt.end_of_line(), default_fmt.end_of_line());
1632 assert_eq!(fmt.exclude_globs(), default_fmt.exclude_globs());
1633 assert_eq!(fmt.link_def_placement(), default_fmt.link_def_placement());
1634 assert_eq!(fmt.link_def_style(), default_fmt.link_def_style());
1635 assert_eq!(fmt.footnote_placement(), default_fmt.footnote_placement());
1636 assert_eq!(fmt.table(), default_fmt.table());
1637 assert_eq!(fmt.list_continuation_indent(), default_fmt.list_continuation_indent());
1638 assert_eq!(fmt.preserve_frontmatter(), default_fmt.preserve_frontmatter());
1639 assert_eq!(fmt.heading_attrs(), default_fmt.heading_attrs());
1640 assert!(!fmt.math().normalise);
1641 assert_eq!(fmt.math().render, MathRender::None);
1642
1643 assert!(generated.contains("[lint.info-strings]"));
1644 assert!(generated.contains("extra = []"));
1645 assert!(generated.contains("[fmt.math]"));
1646 assert!(generated.contains("render = \"none\""));
1647 assert!(generated.contains("[parse.math]"));
1648 assert!(generated.contains("delimiters = \"tex\""));
1649 assert!(generated.contains("[parse.extensions.gfm]"));
1650 assert!(generated.contains("autolinks = \"urls-and-emails\""));
1651 Ok(())
1652 }
1653
1654 #[test]
1655 fn parse_math_delimiters_default_to_tex() -> Result<()> {
1656 let cfg = config_from_str("")?;
1657 assert_eq!(cfg.parse_options().math().delimiters, MathDelimiterSet::Tex);
1658 Ok(())
1659 }
1660
1661 #[test]
1662 fn parse_math_delimiters_accept_github() -> Result<()> {
1663 let cfg = config_from_str("[parse.math]\ndelimiters = \"github\"\n")?;
1664 assert_eq!(cfg.parse_options().math().delimiters, MathDelimiterSet::Github);
1665 Ok(())
1666 }
1667
1668 #[test]
1669 fn rejects_unknown_top_level_key() -> Result<()> {
1670 let src = "[lnt]\nrules = \"default\"\n";
1671 let err = toml::from_str::<Schema>(src)
1672 .err()
1673 .ok_or_else(|| anyhow!("expected error"))?;
1674 let rendered = err.to_string();
1675 assert!(rendered.contains("lnt"), "error should name 'lnt': {rendered}");
1676 Ok(())
1677 }
1678
1679 #[test]
1680 fn rejects_unknown_inner_key() -> Result<()> {
1681 let src = "[lint]\nrulez = \"default\"\n";
1682 let err = toml::from_str::<Schema>(src)
1683 .err()
1684 .ok_or_else(|| anyhow!("expected error"))?;
1685 let rendered = err.to_string();
1686 assert!(rendered.contains("rulez"), "error should name 'rulez': {rendered}");
1687 Ok(())
1688 }
1689
1690 #[test]
1691 fn wrap_schema_accepts_string_or_int() -> Result<()> {
1692 let keep = config_from_str("[fmt]\nwrap = \"keep\"\n")?;
1693 assert_eq!(keep.fmt_options().wrap(), Wrap::Keep);
1694 assert_eq!(keep.fmt_options().wrap().columns(), u32::MAX);
1695 let no = config_from_str("[fmt]\nwrap = \"no\"\n")?;
1696 assert_eq!(no.fmt_options().wrap(), Wrap::No);
1697 assert_eq!(no.fmt_options().wrap().columns(), u32::MAX);
1698 let columns = config_from_str("[fmt]\nwrap = 70\n")?;
1699 assert_eq!(columns.fmt_options().wrap(), Wrap::At(70));
1700 assert_eq!(columns.fmt_options().wrap().columns(), 70);
1701 Ok(())
1702 }
1703
1704 #[test]
1705 fn parse_extensions_are_parse_policy() -> Result<()> {
1706 let cfg = config_from_str(
1707 r#"
1708[parse.extensions]
1709definition-lists = false
1710heading-attribute-lists = false
1711
1712[parse.extensions.gfm]
1713autolinks = "disabled"
1714tagfilter = false
1715
1716[parse.extensions.myst]
1717comments = false
1718
1719[parse.extensions.pandoc]
1720inline-attribute-spans = false
1721"#,
1722 )?;
1723 let extensions = cfg.parse_options().extensions();
1724 assert_eq!(extensions.gfm.autolinks, GfmAutolinkPolicy::Disabled);
1725 assert!(!extensions.gfm.tagfilter);
1726 assert!(!extensions.definition_lists);
1727 assert!(!extensions.heading_attribute_lists);
1728 assert!(!extensions.myst.comments);
1729 assert!(!extensions.pandoc.inline_attribute_spans);
1730 Ok(())
1731 }
1732
1733 #[test]
1734 fn render_profile_is_render_policy() -> Result<()> {
1735 let default = config_from_str("")?;
1736 assert_eq!(default.render_options().profile(), RenderProfile::Pulldown);
1737
1738 let cfg = config_from_str("[render]\nprofile = \"cmark-gfm\"\n")?;
1739 assert_eq!(cfg.render_options().profile(), RenderProfile::CmarkGfm);
1740 Ok(())
1741 }
1742
1743 #[test]
1744 fn rejects_unknown_render_profile() -> Result<()> {
1745 let err = config_from_str("[render]\nprofile = \"github\"\n")
1746 .err()
1747 .ok_or_else(|| anyhow!("expected error"))?;
1748 assert!(
1749 err.to_string().contains("profile"),
1750 "error should name rejected render profile: {err}"
1751 );
1752 Ok(())
1753 }
1754
1755 #[test]
1756 fn fmt_profile_mdformat_sets_compatible_defaults() -> Result<()> {
1757 let cfg = config_from_str("[fmt]\nprofile = \"mdformat\"\n")?;
1758 let fmt = cfg.fmt_options();
1759 assert_eq!(fmt.wrap(), Wrap::Keep);
1760 assert_eq!(fmt.wrap_strategy(), WrapStrategy::Stable);
1761 assert_eq!(fmt.italic(), ItalicStyle::Preserve);
1762 assert_eq!(fmt.strong(), StrongStyle::Preserve);
1763 assert_eq!(fmt.list_marker(), ListMarkerStyle::Dash);
1764 assert_eq!(fmt.list_continuation_indent(), ListContinuationIndent::FourSpace);
1765 assert_eq!(fmt.ordered_list(), OrderedListStyle::One);
1766 assert_eq!(fmt.thematic_break_style(), ThematicStyle::Underscore70);
1767 assert_eq!(fmt.table(), TableStyle::Align);
1768 assert!(fmt.preserve_frontmatter());
1769 Ok(())
1770 }
1771
1772 #[test]
1773 fn explicit_fmt_keys_override_mdformat_profile() -> Result<()> {
1774 let cfg = config_from_str(
1775 r#"
1776[fmt]
1777profile = "mdformat"
1778wrap = 120
1779wrap-strategy = "balanced"
1780list-marker = "plus"
1781ordered-list = "consistent"
1782thematic-break = "dash"
1783
1784[fmt.lists]
1785continuation-indent = "marker-width"
1786
1787[fmt.tables]
1788style = "preserve"
1789"#,
1790 )?;
1791 let fmt = cfg.fmt_options();
1792 assert_eq!(fmt.wrap(), Wrap::At(120));
1793 assert_eq!(fmt.wrap_strategy(), WrapStrategy::Balanced);
1794 assert_eq!(fmt.list_marker(), ListMarkerStyle::Plus);
1795 assert_eq!(fmt.ordered_list(), OrderedListStyle::Consistent);
1796 assert_eq!(fmt.list_continuation_indent(), ListContinuationIndent::MarkerWidth);
1797 assert_eq!(fmt.thematic_break_style(), ThematicStyle::Dash);
1798 assert_eq!(fmt.table(), TableStyle::Preserve);
1799 Ok(())
1800 }
1801
1802 #[test]
1803 fn fmt_wrap_strategy_accepts_supported_styles() -> Result<()> {
1804 let stable = config_from_str("[fmt]\nwrap-strategy = \"stable\"\n")?;
1805 assert_eq!(stable.fmt_options().wrap_strategy(), WrapStrategy::Stable);
1806
1807 let balanced = config_from_str("[fmt]\nwrap-strategy = \"balanced\"\n")?;
1808 assert_eq!(balanced.fmt_options().wrap_strategy(), WrapStrategy::Balanced);
1809
1810 let err = config_from_str("[fmt]\nwrap-strategy = \"pretty\"\n")
1811 .err()
1812 .ok_or_else(|| anyhow!("expected wrap-strategy error"))?;
1813 assert!(
1814 err.to_string().contains("wrap-strategy"),
1815 "error should name wrap-strategy: {err}"
1816 );
1817 Ok(())
1818 }
1819
1820 #[test]
1821 fn fmt_lists_continuation_indent_accepts_supported_styles() -> Result<()> {
1822 let marker_width = config_from_str("[fmt.lists]\ncontinuation-indent = \"marker-width\"\n")?;
1823 assert_eq!(
1824 marker_width.fmt_options().list_continuation_indent(),
1825 ListContinuationIndent::MarkerWidth
1826 );
1827
1828 let four_space = config_from_str("[fmt.lists]\ncontinuation-indent = \"four-space\"\n")?;
1829 assert_eq!(
1830 four_space.fmt_options().list_continuation_indent(),
1831 ListContinuationIndent::FourSpace
1832 );
1833
1834 let err = config_from_str("[fmt.lists]\ncontinuation-indent = \"tab\"\n")
1835 .err()
1836 .ok_or_else(|| anyhow!("expected continuation-indent error"))?;
1837 assert!(
1838 err.to_string().contains("continuation-indent"),
1839 "error should name rejected continuation-indent: {err}"
1840 );
1841 Ok(())
1842 }
1843
1844 #[test]
1845 fn fmt_tables_style_accepts_supported_styles() -> Result<()> {
1846 let compact = config_from_str("[fmt.tables]\nstyle = \"compact\"\n")?;
1847 assert_eq!(compact.fmt_options().table(), TableStyle::Compact);
1848
1849 let align = config_from_str("[fmt.tables]\nstyle = \"align\"\n")?;
1850 assert_eq!(align.fmt_options().table(), TableStyle::Align);
1851
1852 let preserve = config_from_str("[fmt.tables]\nstyle = \"preserve\"\n")?;
1853 assert_eq!(preserve.fmt_options().table(), TableStyle::Preserve);
1854
1855 let pad = config_from_str("[fmt.tables]\nstyle = \"pad\"\n")
1856 .err()
1857 .ok_or_else(|| anyhow!("expected table style error"))?;
1858 assert!(
1859 pad.to_string().contains("style"),
1860 "error should name rejected table style: {pad}"
1861 );
1862 Ok(())
1863 }
1864
1865 #[test]
1866 fn rejects_unknown_fmt_profile_and_table_style() -> Result<()> {
1867 let profile = config_from_str("[fmt]\nprofile = \"aggressive\"\n")
1868 .err()
1869 .ok_or_else(|| anyhow!("expected profile error"))?;
1870 assert!(
1871 profile.to_string().contains("profile"),
1872 "error should name profile: {profile}"
1873 );
1874
1875 let table = config_from_str("[fmt.tables]\nstyle = \"wide\"\n")
1876 .err()
1877 .ok_or_else(|| anyhow!("expected table style error"))?;
1878 assert!(
1879 table.to_string().contains("style"),
1880 "error should name table style: {table}"
1881 );
1882 Ok(())
1883 }
1884
1885 #[test]
1886 fn formatter_extension_table_is_not_a_schema_key() -> Result<()> {
1887 let src = concat!("[fmt", ".extensions]\ndefinition-lists = false\n");
1888 let err = toml::from_str::<Schema>(src)
1889 .err()
1890 .ok_or_else(|| anyhow!("expected error"))?;
1891 let rendered = err.to_string();
1892 assert!(
1893 rendered.contains("extensions"),
1894 "error should name rejected formatter extension table: {rendered}"
1895 );
1896 Ok(())
1897 }
1898
1899 #[test]
1900 fn resolvers_honour_style() -> Result<()> {
1901 let preserve = config_from_str("[fmt]\nitalic = \"preserve\"\nlist-marker = \"preserve\"\n")?;
1902 let fmt = preserve.fmt_options();
1903 assert_eq!(fmt.resolve_italic(b'_'), b'_');
1904 assert_eq!(fmt.resolve_italic(b'*'), b'*');
1905 assert_eq!(fmt.resolve_list_marker(b'+'), b'+');
1906
1907 let pin = config_from_str("[fmt]\nitalic = \"asterisk\"\nlist-marker = \"dash\"\n")?;
1908 let fmt = pin.fmt_options();
1909 assert_eq!(fmt.resolve_italic(b'_'), b'*');
1910 assert_eq!(fmt.resolve_list_marker(b'*'), b'-');
1911
1912 let defaults = FmtOptions::default();
1916 assert_eq!(defaults.resolve_italic(b'_'), b'_');
1917 assert_eq!(defaults.resolve_italic(b'*'), b'*');
1918 assert_eq!(defaults.resolve_list_marker(b'+'), b'+');
1919 assert_eq!(defaults.resolve_list_marker(b'-'), b'-');
1920 Ok(())
1921 }
1922
1923 #[test]
1924 fn style_enums_round_trip() -> Result<()> {
1925 for (lit, expected) in [
1926 ("\"asterisk\"", ItalicStyle::Asterisk),
1927 ("\"underscore\"", ItalicStyle::Underscore),
1928 ("\"preserve\"", ItalicStyle::Preserve),
1929 ] {
1930 let cfg = config_from_str(&format!("[fmt]\nitalic = {lit}\n"))?;
1931 assert_eq!(cfg.fmt_options().italic(), expected);
1932 }
1933 for (lit, expected) in [
1934 ("\"asterisk\"", StrongStyle::Asterisk),
1935 ("\"underscore\"", StrongStyle::Underscore),
1936 ("\"preserve\"", StrongStyle::Preserve),
1937 ] {
1938 let cfg = config_from_str(&format!("[fmt]\nstrong = {lit}\n"))?;
1939 assert_eq!(cfg.fmt_options().strong(), expected);
1940 }
1941 for (lit, expected) in [
1942 ("\"dash\"", ThematicStyle::Dash),
1943 ("\"asterisk\"", ThematicStyle::Asterisk),
1944 ("\"underscore\"", ThematicStyle::Underscore),
1945 ("\"underscore-70\"", ThematicStyle::Underscore70),
1946 ("\"preserve\"", ThematicStyle::Preserve),
1947 ] {
1948 let cfg = config_from_str(&format!("[fmt]\nthematic-break = {lit}\n"))?;
1949 assert_eq!(cfg.fmt_options().thematic_break_style(), expected);
1950 }
1951 for (lit, expected) in [
1952 ("\"dash\"", ListMarkerStyle::Dash),
1953 ("\"asterisk\"", ListMarkerStyle::Asterisk),
1954 ("\"plus\"", ListMarkerStyle::Plus),
1955 ("\"preserve\"", ListMarkerStyle::Preserve),
1956 ] {
1957 let cfg = config_from_str(&format!("[fmt]\nlist-marker = {lit}\n"))?;
1958 assert_eq!(cfg.fmt_options().list_marker(), expected);
1959 }
1960 for (lit, expected) in [
1961 ("\"one\"", OrderedListStyle::One),
1962 ("\"consistent\"", OrderedListStyle::Consistent),
1963 ("\"preserve\"", OrderedListStyle::Preserve),
1964 ] {
1965 let cfg = config_from_str(&format!("[fmt]\nordered-list = {lit}\n"))?;
1966 assert_eq!(cfg.fmt_options().ordered_list(), expected);
1967 }
1968 for (lit, expected) in [
1969 ("\"lf\"", EndOfLine::Lf),
1970 ("\"crlf\"", EndOfLine::Crlf),
1971 ("\"keep\"", EndOfLine::Keep),
1972 ] {
1973 let cfg = config_from_str(&format!("[fmt]\nend-of-line = {lit}\n"))?;
1974 assert_eq!(cfg.fmt_options().end_of_line(), expected);
1975 }
1976 Ok(())
1977 }
1978}