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: &str) {
227 if pattern.contains(',') && !pattern.contains('{') {
228 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
229 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
230 eprintln!(" Or use separate entries for each file.");
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
242 fn test_display_all_variants_are_lowercase() {
243 let cases = [
244 (MarkdownFlavor::Standard, "standard"),
245 (MarkdownFlavor::MkDocs, "mkdocs"),
246 (MarkdownFlavor::MDX, "mdx"),
247 (MarkdownFlavor::Pandoc, "pandoc"),
248 (MarkdownFlavor::Quarto, "quarto"),
249 (MarkdownFlavor::Obsidian, "obsidian"),
250 (MarkdownFlavor::Kramdown, "kramdown"),
251 (MarkdownFlavor::AzureDevOps, "azure_devops"),
252 (MarkdownFlavor::MyST, "myst"),
253 (MarkdownFlavor::Hugo, "hugo"),
254 ];
255 for (variant, expected) in cases {
256 let displayed = variant.to_string();
257 assert_eq!(
258 displayed, expected,
259 "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
260 );
261 assert!(
263 displayed.chars().all(|c| !c.is_ascii_uppercase()),
264 "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
265 );
266 }
267 }
268
269 #[test]
272 fn test_display_round_trips_through_from_str() {
273 let variants = [
274 MarkdownFlavor::Standard,
275 MarkdownFlavor::MkDocs,
276 MarkdownFlavor::MDX,
277 MarkdownFlavor::Pandoc,
278 MarkdownFlavor::Quarto,
279 MarkdownFlavor::Obsidian,
280 MarkdownFlavor::Kramdown,
281 MarkdownFlavor::AzureDevOps,
282 MarkdownFlavor::MyST,
283 MarkdownFlavor::Hugo,
284 ];
285 for variant in variants {
286 let displayed = variant.to_string();
287 let parsed: MarkdownFlavor = displayed
288 .parse()
289 .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
290 assert_eq!(
291 parsed, variant,
292 "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
293 );
294 }
295 }
296
297 #[test]
298 fn test_pandoc_from_str() {
299 assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
300 assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
301 }
302
303 #[test]
304 fn test_pandoc_name_and_display() {
305 assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
306 assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
307 }
308
309 #[test]
310 fn test_from_extension_does_not_auto_detect_pandoc() {
311 assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
313 assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
314 }
315
316 #[test]
317 fn test_is_pandoc_compatible() {
318 assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
319 assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
320
321 assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
322 assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
323 assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
324 assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
325 assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
326 }
327
328 #[test]
329 fn test_azure_devops_from_str() {
330 assert_eq!(
331 "azure_devops".parse::<MarkdownFlavor>().unwrap(),
332 MarkdownFlavor::AzureDevOps
333 );
334 assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
335 assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
336 assert_eq!(
337 "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
338 MarkdownFlavor::AzureDevOps
339 );
340 }
341
342 #[test]
343 fn test_azure_devops_display_and_round_trip() {
344 assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
345 let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
346 assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
347 }
348
349 #[test]
350 fn test_supports_colon_code_fences() {
351 assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
352 assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
353 assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
354 assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
355 assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
356 assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
357 assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
358 assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
359 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
360 }
361
362 #[test]
363 fn test_azure_devops_not_pandoc_compatible() {
364 assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
365 }
366
367 #[test]
368 fn test_display_all_variants_covers_azure_devops() {
369 let displayed = MarkdownFlavor::AzureDevOps.to_string();
370 assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
371 }
372
373 #[test]
374 fn test_myst_from_str() {
375 assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
376 assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
377 assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
378 }
379
380 #[test]
381 fn test_myst_display_and_round_trip() {
382 assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
383 let parsed: MarkdownFlavor = "myst".parse().unwrap();
384 assert_eq!(parsed, MarkdownFlavor::MyST);
385 }
386
387 #[test]
388 fn test_myst_capabilities() {
389 assert!(MarkdownFlavor::MyST.supports_myst_directives());
390 assert!(MarkdownFlavor::MyST.supports_myst_roles());
391 assert!(MarkdownFlavor::MyST.supports_myst_comments());
392 assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
393 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
394 assert!(!MarkdownFlavor::MyST.supports_jsx());
395
396 assert!(!MarkdownFlavor::Standard.supports_myst_directives());
397 assert!(!MarkdownFlavor::Standard.supports_myst_roles());
398 assert!(!MarkdownFlavor::Standard.supports_myst_comments());
399 }
400
401 #[test]
402 fn test_myst_name() {
403 assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
404 }
405
406 #[test]
407 fn test_hugo_from_str() {
408 assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
409 assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
410 assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
411 }
412
413 #[test]
414 fn test_hugo_display_name_and_round_trip() {
415 assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
416 assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
417 let parsed: MarkdownFlavor = "hugo".parse().unwrap();
418 assert_eq!(parsed, MarkdownFlavor::Hugo);
419 }
420
421 #[test]
422 fn test_hugo_supports_attr_lists() {
423 assert!(MarkdownFlavor::Hugo.supports_attr_lists());
425 assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
426 assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
427
428 assert!(!MarkdownFlavor::Standard.supports_attr_lists());
430 assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
431
432 assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
434 assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
435 assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
436 }
437}