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 #[serde(rename = "mdg", alias = "markdown_with_gherkin")]
57 MDG,
58}
59
60fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
62 schemars::json_schema!({
63 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg. 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, markdown_with_gherkin maps to mdg.",
64 "type": "string",
65 "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd", "hugo", "goldmark", "mdg", "markdown_with_gherkin"]
66 })
67}
68
69impl schemars::JsonSchema for MarkdownFlavor {
70 fn schema_name() -> std::borrow::Cow<'static, str> {
71 std::borrow::Cow::Borrowed("MarkdownFlavor")
72 }
73
74 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
75 markdown_flavor_schema(generator)
76 }
77}
78
79impl fmt::Display for MarkdownFlavor {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 MarkdownFlavor::Standard => write!(f, "standard"),
83 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
84 MarkdownFlavor::MDX => write!(f, "mdx"),
85 MarkdownFlavor::Pandoc => write!(f, "pandoc"),
86 MarkdownFlavor::Quarto => write!(f, "quarto"),
87 MarkdownFlavor::Obsidian => write!(f, "obsidian"),
88 MarkdownFlavor::Kramdown => write!(f, "kramdown"),
89 MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
90 MarkdownFlavor::MyST => write!(f, "myst"),
91 MarkdownFlavor::Hugo => write!(f, "hugo"),
92 MarkdownFlavor::MDG => write!(f, "mdg"),
93 }
94 }
95}
96
97impl FromStr for MarkdownFlavor {
98 type Err = String;
99
100 fn from_str(s: &str) -> Result<Self, Self::Err> {
101 match s.to_lowercase().as_str() {
102 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
103 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
104 "mdx" => Ok(MarkdownFlavor::MDX),
105 "pandoc" => Ok(MarkdownFlavor::Pandoc),
106 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
107 "obsidian" => Ok(MarkdownFlavor::Obsidian),
108 "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
109 "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
110 "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
111 "hugo" | "goldmark" => Ok(MarkdownFlavor::Hugo),
112 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
116 "mdg" | "markdown_with_gherkin" => Ok(MarkdownFlavor::MDG),
117 _ => Err(format!("Unknown markdown flavor: {s}")),
118 }
119 }
120}
121
122impl MarkdownFlavor {
123 pub fn from_extension(ext: &str) -> Self {
125 match ext.to_lowercase().as_str() {
126 "mdx" => Self::MDX,
127 "qmd" => Self::Quarto,
128 "rmd" => Self::Quarto,
129 "kramdown" => Self::Kramdown,
130 _ => Self::Standard,
131 }
132 }
133
134 pub fn from_path(path: &std::path::Path) -> Self {
136 if path
137 .file_name()
138 .and_then(|name| name.to_str())
139 .is_some_and(|name| name.to_ascii_lowercase().ends_with(".feature.md"))
140 {
141 return Self::MDG;
142 }
143
144 path.extension()
145 .and_then(|e| e.to_str())
146 .map_or(Self::Standard, Self::from_extension)
147 }
148
149 pub fn supports_esm_blocks(self) -> bool {
151 matches!(self, Self::MDX)
152 }
153
154 pub fn supports_jsx(self) -> bool {
156 matches!(self, Self::MDX)
157 }
158
159 pub fn supports_auto_references(self) -> bool {
161 matches!(self, Self::MkDocs)
162 }
163
164 pub fn supports_kramdown_syntax(self) -> bool {
166 matches!(self, Self::Kramdown)
167 }
168
169 pub fn supports_attr_lists(self) -> bool {
171 matches!(self, Self::MkDocs | Self::Kramdown | Self::Hugo)
172 }
173
174 pub fn requires_strict_list_indent(self) -> bool {
179 matches!(self, Self::MkDocs)
180 }
181
182 pub fn is_pandoc_compatible(self) -> bool {
186 matches!(self, Self::Pandoc | Self::Quarto)
187 }
188
189 pub fn name(self) -> &'static str {
191 match self {
192 Self::Standard => "Standard",
193 Self::MkDocs => "MkDocs",
194 Self::MDX => "MDX",
195 Self::Pandoc => "Pandoc",
196 Self::Quarto => "Quarto",
197 Self::Obsidian => "Obsidian",
198 Self::Kramdown => "Kramdown",
199 Self::AzureDevOps => "AzureDevOps",
200 Self::MyST => "MyST",
201 Self::Hugo => "Hugo",
202 Self::MDG => "Markdown with Gherkin",
203 }
204 }
205
206 pub fn supports_colon_code_fences(self) -> bool {
208 matches!(self, Self::AzureDevOps)
209 }
210
211 pub fn supports_myst_directives(self) -> bool {
213 matches!(self, Self::MyST)
214 }
215
216 pub fn supports_myst_roles(self) -> bool {
218 matches!(self, Self::MyST)
219 }
220
221 pub fn supports_myst_comments(self) -> bool {
223 matches!(self, Self::MyST)
224 }
225}
226
227pub fn normalize_key(key: &str) -> String {
229 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
231 key.to_ascii_uppercase()
232 } else {
233 key.replace('_', "-").to_ascii_lowercase()
234 }
235}
236
237pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: super::types::ConfigRef<'_>) {
245 if pattern.contains(',') && !pattern.contains('{') {
246 if config_file.may_quote_contents() {
247 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
248 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
249 } else {
250 eprintln!("Warning: A pattern in {config_file} contains a comma but no braces.");
251 eprintln!(" To match multiple files, wrap the pattern in braces for brace expansion.");
252 }
253 eprintln!(" Or use separate entries for each file.");
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
265 fn test_display_all_variants_are_lowercase() {
266 let cases = [
267 (MarkdownFlavor::Standard, "standard"),
268 (MarkdownFlavor::MkDocs, "mkdocs"),
269 (MarkdownFlavor::MDX, "mdx"),
270 (MarkdownFlavor::Pandoc, "pandoc"),
271 (MarkdownFlavor::Quarto, "quarto"),
272 (MarkdownFlavor::Obsidian, "obsidian"),
273 (MarkdownFlavor::Kramdown, "kramdown"),
274 (MarkdownFlavor::AzureDevOps, "azure_devops"),
275 (MarkdownFlavor::MyST, "myst"),
276 (MarkdownFlavor::Hugo, "hugo"),
277 (MarkdownFlavor::MDG, "mdg"),
278 ];
279 for (variant, expected) in cases {
280 let displayed = variant.to_string();
281 assert_eq!(
282 displayed, expected,
283 "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
284 );
285 assert!(
287 displayed.chars().all(|c| !c.is_ascii_uppercase()),
288 "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
289 );
290 }
291 }
292
293 #[test]
296 fn test_display_round_trips_through_from_str() {
297 let variants = [
298 MarkdownFlavor::Standard,
299 MarkdownFlavor::MkDocs,
300 MarkdownFlavor::MDX,
301 MarkdownFlavor::Pandoc,
302 MarkdownFlavor::Quarto,
303 MarkdownFlavor::Obsidian,
304 MarkdownFlavor::Kramdown,
305 MarkdownFlavor::AzureDevOps,
306 MarkdownFlavor::MyST,
307 MarkdownFlavor::Hugo,
308 MarkdownFlavor::MDG,
309 ];
310 for variant in variants {
311 let displayed = variant.to_string();
312 let parsed: MarkdownFlavor = displayed
313 .parse()
314 .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
315 assert_eq!(
316 parsed, variant,
317 "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
318 );
319 }
320 }
321
322 #[test]
323 fn test_pandoc_from_str() {
324 assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
325 assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
326 }
327
328 #[test]
329 fn test_pandoc_name_and_display() {
330 assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
331 assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
332 }
333
334 #[test]
335 fn test_from_extension_does_not_auto_detect_pandoc() {
336 assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
338 assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
339 }
340
341 #[test]
342 fn test_mdg_aliases_and_name() {
343 assert_eq!("MDG".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MDG);
344 assert_eq!(
345 "markdown_with_gherkin".parse::<MarkdownFlavor>().unwrap(),
346 MarkdownFlavor::MDG
347 );
348 assert_eq!(MarkdownFlavor::MDG.name(), "Markdown with Gherkin");
349 }
350
351 #[test]
352 fn test_mdg_serde_names() {
353 assert_eq!(
354 MarkdownFlavor::deserialize(toml::Value::String("markdown_with_gherkin".to_string())).unwrap(),
355 MarkdownFlavor::MDG
356 );
357 assert_eq!(
358 toml::Value::try_from(MarkdownFlavor::MDG).unwrap().as_str(),
359 Some("mdg")
360 );
361 }
362
363 #[test]
364 fn test_from_path_detects_feature_md_compound_suffix() {
365 use std::path::Path;
366
367 assert_eq!(
368 MarkdownFlavor::from_path(Path::new("features/login.feature.md")),
369 MarkdownFlavor::MDG
370 );
371 assert_eq!(
372 MarkdownFlavor::from_path(Path::new("features/login.FEATURE.MD")),
373 MarkdownFlavor::MDG
374 );
375 assert_eq!(
376 MarkdownFlavor::from_path(Path::new("README.md")),
377 MarkdownFlavor::Standard
378 );
379 assert_eq!(
380 MarkdownFlavor::from_path(Path::new("features/login.feature.markdown")),
381 MarkdownFlavor::Standard
382 );
383 }
384
385 #[test]
386 fn test_is_pandoc_compatible() {
387 assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
388 assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
389
390 assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
391 assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
392 assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
393 assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
394 assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
395 }
396
397 #[test]
398 fn test_azure_devops_from_str() {
399 assert_eq!(
400 "azure_devops".parse::<MarkdownFlavor>().unwrap(),
401 MarkdownFlavor::AzureDevOps
402 );
403 assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
404 assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
405 assert_eq!(
406 "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
407 MarkdownFlavor::AzureDevOps
408 );
409 }
410
411 #[test]
412 fn test_azure_devops_display_and_round_trip() {
413 assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
414 let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
415 assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
416 }
417
418 #[test]
419 fn test_supports_colon_code_fences() {
420 assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
421 assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
422 assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
423 assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
424 assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
425 assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
426 assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
427 assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
428 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
429 }
430
431 #[test]
432 fn test_azure_devops_not_pandoc_compatible() {
433 assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
434 }
435
436 #[test]
437 fn test_display_all_variants_covers_azure_devops() {
438 let displayed = MarkdownFlavor::AzureDevOps.to_string();
439 assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
440 }
441
442 #[test]
443 fn test_myst_from_str() {
444 assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
445 assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
446 assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
447 }
448
449 #[test]
450 fn test_myst_display_and_round_trip() {
451 assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
452 let parsed: MarkdownFlavor = "myst".parse().unwrap();
453 assert_eq!(parsed, MarkdownFlavor::MyST);
454 }
455
456 #[test]
457 fn test_myst_capabilities() {
458 assert!(MarkdownFlavor::MyST.supports_myst_directives());
459 assert!(MarkdownFlavor::MyST.supports_myst_roles());
460 assert!(MarkdownFlavor::MyST.supports_myst_comments());
461 assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
462 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
463 assert!(!MarkdownFlavor::MyST.supports_jsx());
464
465 assert!(!MarkdownFlavor::Standard.supports_myst_directives());
466 assert!(!MarkdownFlavor::Standard.supports_myst_roles());
467 assert!(!MarkdownFlavor::Standard.supports_myst_comments());
468 }
469
470 #[test]
471 fn test_myst_name() {
472 assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
473 }
474
475 #[test]
476 fn test_hugo_from_str() {
477 assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
478 assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
479 assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
480 }
481
482 #[test]
483 fn test_hugo_display_name_and_round_trip() {
484 assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
485 assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
486 let parsed: MarkdownFlavor = "hugo".parse().unwrap();
487 assert_eq!(parsed, MarkdownFlavor::Hugo);
488 }
489
490 #[test]
491 fn test_hugo_supports_attr_lists() {
492 assert!(MarkdownFlavor::Hugo.supports_attr_lists());
494 assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
495 assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
496
497 assert!(!MarkdownFlavor::Standard.supports_attr_lists());
499 assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
500
501 assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
503 assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
504 assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
505 }
506}