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