1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, Copy, Default)]
12pub struct ConfigLoaded;
13
14#[derive(Debug, Clone, Copy, Default)]
17pub struct ConfigValidated;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23 #[serde(rename = "standard", alias = "none", alias = "")]
25 #[default]
26 Standard,
27 #[serde(rename = "mkdocs")]
29 MkDocs,
30 #[serde(rename = "mdx")]
32 MDX,
33 #[serde(rename = "pandoc")]
36 Pandoc,
37 #[serde(rename = "quarto")]
39 Quarto,
40 #[serde(rename = "obsidian")]
42 Obsidian,
43 #[serde(rename = "kramdown")]
45 Kramdown,
46 #[serde(rename = "azure_devops", alias = "azure", alias = "ado")]
48 AzureDevOps,
49 #[serde(rename = "myst", alias = "mystmd")]
51 MyST,
52 #[serde(rename = "hugo", alias = "goldmark")]
54 Hugo,
55}
56
57fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
59 schemars::json_schema!({
60 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown, azure/ado map to azure_devops, mystmd maps to myst, goldmark maps to hugo.",
61 "type": "string",
62 "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd", "hugo", "goldmark"]
63 })
64}
65
66impl schemars::JsonSchema for MarkdownFlavor {
67 fn schema_name() -> std::borrow::Cow<'static, str> {
68 std::borrow::Cow::Borrowed("MarkdownFlavor")
69 }
70
71 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
72 markdown_flavor_schema(generator)
73 }
74}
75
76impl fmt::Display for MarkdownFlavor {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 MarkdownFlavor::Standard => write!(f, "standard"),
80 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
81 MarkdownFlavor::MDX => write!(f, "mdx"),
82 MarkdownFlavor::Pandoc => write!(f, "pandoc"),
83 MarkdownFlavor::Quarto => write!(f, "quarto"),
84 MarkdownFlavor::Obsidian => write!(f, "obsidian"),
85 MarkdownFlavor::Kramdown => write!(f, "kramdown"),
86 MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
87 MarkdownFlavor::MyST => write!(f, "myst"),
88 MarkdownFlavor::Hugo => write!(f, "hugo"),
89 }
90 }
91}
92
93impl FromStr for MarkdownFlavor {
94 type Err = String;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 match s.to_lowercase().as_str() {
98 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
99 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
100 "mdx" => Ok(MarkdownFlavor::MDX),
101 "pandoc" => Ok(MarkdownFlavor::Pandoc),
102 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
103 "obsidian" => Ok(MarkdownFlavor::Obsidian),
104 "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
105 "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
106 "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
107 "hugo" | "goldmark" => Ok(MarkdownFlavor::Hugo),
108 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
112 _ => Err(format!("Unknown markdown flavor: {s}")),
113 }
114 }
115}
116
117impl MarkdownFlavor {
118 pub fn from_extension(ext: &str) -> Self {
120 match ext.to_lowercase().as_str() {
121 "mdx" => Self::MDX,
122 "qmd" => Self::Quarto,
123 "rmd" => Self::Quarto,
124 "kramdown" => Self::Kramdown,
125 _ => Self::Standard,
126 }
127 }
128
129 pub fn from_path(path: &std::path::Path) -> Self {
131 path.extension()
132 .and_then(|e| e.to_str())
133 .map_or(Self::Standard, Self::from_extension)
134 }
135
136 pub fn supports_esm_blocks(self) -> bool {
138 matches!(self, Self::MDX)
139 }
140
141 pub fn supports_jsx(self) -> bool {
143 matches!(self, Self::MDX)
144 }
145
146 pub fn supports_auto_references(self) -> bool {
148 matches!(self, Self::MkDocs)
149 }
150
151 pub fn supports_kramdown_syntax(self) -> bool {
153 matches!(self, Self::Kramdown)
154 }
155
156 pub fn supports_attr_lists(self) -> bool {
158 matches!(self, Self::MkDocs | Self::Kramdown | Self::Hugo)
159 }
160
161 pub fn requires_strict_list_indent(self) -> bool {
166 matches!(self, Self::MkDocs)
167 }
168
169 pub fn is_pandoc_compatible(self) -> bool {
173 matches!(self, Self::Pandoc | Self::Quarto)
174 }
175
176 pub fn name(self) -> &'static str {
178 match self {
179 Self::Standard => "Standard",
180 Self::MkDocs => "MkDocs",
181 Self::MDX => "MDX",
182 Self::Pandoc => "Pandoc",
183 Self::Quarto => "Quarto",
184 Self::Obsidian => "Obsidian",
185 Self::Kramdown => "Kramdown",
186 Self::AzureDevOps => "AzureDevOps",
187 Self::MyST => "MyST",
188 Self::Hugo => "Hugo",
189 }
190 }
191
192 pub fn supports_colon_code_fences(self) -> bool {
194 matches!(self, Self::AzureDevOps)
195 }
196
197 pub fn supports_myst_directives(self) -> bool {
199 matches!(self, Self::MyST)
200 }
201
202 pub fn supports_myst_roles(self) -> bool {
204 matches!(self, Self::MyST)
205 }
206
207 pub fn supports_myst_comments(self) -> bool {
209 matches!(self, Self::MyST)
210 }
211}
212
213pub fn normalize_key(key: &str) -> String {
215 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
217 key.to_ascii_uppercase()
218 } else {
219 key.replace('_', "-").to_ascii_lowercase()
220 }
221}
222
223pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: super::types::ConfigRef<'_>) {
231 if pattern.contains(',') && !pattern.contains('{') {
232 if config_file.may_quote_contents() {
233 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
234 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
235 } else {
236 eprintln!("Warning: A pattern in {config_file} contains a comma but no braces.");
237 eprintln!(" To match multiple files, wrap the pattern in braces for brace expansion.");
238 }
239 eprintln!(" Or use separate entries for each file.");
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
251 fn test_display_all_variants_are_lowercase() {
252 let cases = [
253 (MarkdownFlavor::Standard, "standard"),
254 (MarkdownFlavor::MkDocs, "mkdocs"),
255 (MarkdownFlavor::MDX, "mdx"),
256 (MarkdownFlavor::Pandoc, "pandoc"),
257 (MarkdownFlavor::Quarto, "quarto"),
258 (MarkdownFlavor::Obsidian, "obsidian"),
259 (MarkdownFlavor::Kramdown, "kramdown"),
260 (MarkdownFlavor::AzureDevOps, "azure_devops"),
261 (MarkdownFlavor::MyST, "myst"),
262 (MarkdownFlavor::Hugo, "hugo"),
263 ];
264 for (variant, expected) in cases {
265 let displayed = variant.to_string();
266 assert_eq!(
267 displayed, expected,
268 "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
269 );
270 assert!(
272 displayed.chars().all(|c| !c.is_ascii_uppercase()),
273 "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
274 );
275 }
276 }
277
278 #[test]
281 fn test_display_round_trips_through_from_str() {
282 let variants = [
283 MarkdownFlavor::Standard,
284 MarkdownFlavor::MkDocs,
285 MarkdownFlavor::MDX,
286 MarkdownFlavor::Pandoc,
287 MarkdownFlavor::Quarto,
288 MarkdownFlavor::Obsidian,
289 MarkdownFlavor::Kramdown,
290 MarkdownFlavor::AzureDevOps,
291 MarkdownFlavor::MyST,
292 MarkdownFlavor::Hugo,
293 ];
294 for variant in variants {
295 let displayed = variant.to_string();
296 let parsed: MarkdownFlavor = displayed
297 .parse()
298 .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
299 assert_eq!(
300 parsed, variant,
301 "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
302 );
303 }
304 }
305
306 #[test]
307 fn test_pandoc_from_str() {
308 assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
309 assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
310 }
311
312 #[test]
313 fn test_pandoc_name_and_display() {
314 assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
315 assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
316 }
317
318 #[test]
319 fn test_from_extension_does_not_auto_detect_pandoc() {
320 assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
322 assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
323 }
324
325 #[test]
326 fn test_is_pandoc_compatible() {
327 assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
328 assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
329
330 assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
331 assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
332 assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
333 assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
334 assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
335 }
336
337 #[test]
338 fn test_azure_devops_from_str() {
339 assert_eq!(
340 "azure_devops".parse::<MarkdownFlavor>().unwrap(),
341 MarkdownFlavor::AzureDevOps
342 );
343 assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
344 assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
345 assert_eq!(
346 "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
347 MarkdownFlavor::AzureDevOps
348 );
349 }
350
351 #[test]
352 fn test_azure_devops_display_and_round_trip() {
353 assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
354 let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
355 assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
356 }
357
358 #[test]
359 fn test_supports_colon_code_fences() {
360 assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
361 assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
362 assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
363 assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
364 assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
365 assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
366 assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
367 assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
368 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
369 }
370
371 #[test]
372 fn test_azure_devops_not_pandoc_compatible() {
373 assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
374 }
375
376 #[test]
377 fn test_display_all_variants_covers_azure_devops() {
378 let displayed = MarkdownFlavor::AzureDevOps.to_string();
379 assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
380 }
381
382 #[test]
383 fn test_myst_from_str() {
384 assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
385 assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
386 assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
387 }
388
389 #[test]
390 fn test_myst_display_and_round_trip() {
391 assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
392 let parsed: MarkdownFlavor = "myst".parse().unwrap();
393 assert_eq!(parsed, MarkdownFlavor::MyST);
394 }
395
396 #[test]
397 fn test_myst_capabilities() {
398 assert!(MarkdownFlavor::MyST.supports_myst_directives());
399 assert!(MarkdownFlavor::MyST.supports_myst_roles());
400 assert!(MarkdownFlavor::MyST.supports_myst_comments());
401 assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
402 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
403 assert!(!MarkdownFlavor::MyST.supports_jsx());
404
405 assert!(!MarkdownFlavor::Standard.supports_myst_directives());
406 assert!(!MarkdownFlavor::Standard.supports_myst_roles());
407 assert!(!MarkdownFlavor::Standard.supports_myst_comments());
408 }
409
410 #[test]
411 fn test_myst_name() {
412 assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
413 }
414
415 #[test]
416 fn test_hugo_from_str() {
417 assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
418 assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
419 assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
420 }
421
422 #[test]
423 fn test_hugo_display_name_and_round_trip() {
424 assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
425 assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
426 let parsed: MarkdownFlavor = "hugo".parse().unwrap();
427 assert_eq!(parsed, MarkdownFlavor::Hugo);
428 }
429
430 #[test]
431 fn test_hugo_supports_attr_lists() {
432 assert!(MarkdownFlavor::Hugo.supports_attr_lists());
434 assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
435 assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
436
437 assert!(!MarkdownFlavor::Standard.supports_attr_lists());
439 assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
440
441 assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
443 assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
444 assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
445 }
446}