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}
53
54fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
56 schemars::json_schema!({
57 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst. 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.",
58 "type": "string",
59 "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd"]
60 })
61}
62
63impl schemars::JsonSchema for MarkdownFlavor {
64 fn schema_name() -> std::borrow::Cow<'static, str> {
65 std::borrow::Cow::Borrowed("MarkdownFlavor")
66 }
67
68 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
69 markdown_flavor_schema(generator)
70 }
71}
72
73impl fmt::Display for MarkdownFlavor {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 MarkdownFlavor::Standard => write!(f, "standard"),
77 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
78 MarkdownFlavor::MDX => write!(f, "mdx"),
79 MarkdownFlavor::Pandoc => write!(f, "pandoc"),
80 MarkdownFlavor::Quarto => write!(f, "quarto"),
81 MarkdownFlavor::Obsidian => write!(f, "obsidian"),
82 MarkdownFlavor::Kramdown => write!(f, "kramdown"),
83 MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
84 MarkdownFlavor::MyST => write!(f, "myst"),
85 }
86 }
87}
88
89impl FromStr for MarkdownFlavor {
90 type Err = String;
91
92 fn from_str(s: &str) -> Result<Self, Self::Err> {
93 match s.to_lowercase().as_str() {
94 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
95 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
96 "mdx" => Ok(MarkdownFlavor::MDX),
97 "pandoc" => Ok(MarkdownFlavor::Pandoc),
98 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
99 "obsidian" => Ok(MarkdownFlavor::Obsidian),
100 "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
101 "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
102 "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
103 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
107 _ => Err(format!("Unknown markdown flavor: {s}")),
108 }
109 }
110}
111
112impl MarkdownFlavor {
113 pub fn from_extension(ext: &str) -> Self {
115 match ext.to_lowercase().as_str() {
116 "mdx" => Self::MDX,
117 "qmd" => Self::Quarto,
118 "rmd" => Self::Quarto,
119 "kramdown" => Self::Kramdown,
120 _ => Self::Standard,
121 }
122 }
123
124 pub fn from_path(path: &std::path::Path) -> Self {
126 path.extension()
127 .and_then(|e| e.to_str())
128 .map_or(Self::Standard, Self::from_extension)
129 }
130
131 pub fn supports_esm_blocks(self) -> bool {
133 matches!(self, Self::MDX)
134 }
135
136 pub fn supports_jsx(self) -> bool {
138 matches!(self, Self::MDX)
139 }
140
141 pub fn supports_auto_references(self) -> bool {
143 matches!(self, Self::MkDocs)
144 }
145
146 pub fn supports_kramdown_syntax(self) -> bool {
148 matches!(self, Self::Kramdown)
149 }
150
151 pub fn supports_attr_lists(self) -> bool {
153 matches!(self, Self::MkDocs | Self::Kramdown)
154 }
155
156 pub fn requires_strict_list_indent(self) -> bool {
161 matches!(self, Self::MkDocs)
162 }
163
164 pub fn is_pandoc_compatible(self) -> bool {
168 matches!(self, Self::Pandoc | Self::Quarto)
169 }
170
171 pub fn name(self) -> &'static str {
173 match self {
174 Self::Standard => "Standard",
175 Self::MkDocs => "MkDocs",
176 Self::MDX => "MDX",
177 Self::Pandoc => "Pandoc",
178 Self::Quarto => "Quarto",
179 Self::Obsidian => "Obsidian",
180 Self::Kramdown => "Kramdown",
181 Self::AzureDevOps => "AzureDevOps",
182 Self::MyST => "MyST",
183 }
184 }
185
186 pub fn supports_colon_code_fences(self) -> bool {
188 matches!(self, Self::AzureDevOps)
189 }
190
191 pub fn supports_myst_directives(self) -> bool {
193 matches!(self, Self::MyST)
194 }
195
196 pub fn supports_myst_roles(self) -> bool {
198 matches!(self, Self::MyST)
199 }
200
201 pub fn supports_myst_comments(self) -> bool {
203 matches!(self, Self::MyST)
204 }
205}
206
207pub fn normalize_key(key: &str) -> String {
209 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
211 key.to_ascii_uppercase()
212 } else {
213 key.replace('_', "-").to_ascii_lowercase()
214 }
215}
216
217pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
221 if pattern.contains(',') && !pattern.contains('{') {
222 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
223 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
224 eprintln!(" Or use separate entries for each file.");
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
236 fn test_display_all_variants_are_lowercase() {
237 let cases = [
238 (MarkdownFlavor::Standard, "standard"),
239 (MarkdownFlavor::MkDocs, "mkdocs"),
240 (MarkdownFlavor::MDX, "mdx"),
241 (MarkdownFlavor::Pandoc, "pandoc"),
242 (MarkdownFlavor::Quarto, "quarto"),
243 (MarkdownFlavor::Obsidian, "obsidian"),
244 (MarkdownFlavor::Kramdown, "kramdown"),
245 (MarkdownFlavor::AzureDevOps, "azure_devops"),
246 (MarkdownFlavor::MyST, "myst"),
247 ];
248 for (variant, expected) in cases {
249 let displayed = variant.to_string();
250 assert_eq!(
251 displayed, expected,
252 "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
253 );
254 assert!(
256 displayed.chars().all(|c| !c.is_ascii_uppercase()),
257 "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
258 );
259 }
260 }
261
262 #[test]
265 fn test_display_round_trips_through_from_str() {
266 let variants = [
267 MarkdownFlavor::Standard,
268 MarkdownFlavor::MkDocs,
269 MarkdownFlavor::MDX,
270 MarkdownFlavor::Pandoc,
271 MarkdownFlavor::Quarto,
272 MarkdownFlavor::Obsidian,
273 MarkdownFlavor::Kramdown,
274 MarkdownFlavor::AzureDevOps,
275 MarkdownFlavor::MyST,
276 ];
277 for variant in variants {
278 let displayed = variant.to_string();
279 let parsed: MarkdownFlavor = displayed
280 .parse()
281 .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
282 assert_eq!(
283 parsed, variant,
284 "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
285 );
286 }
287 }
288
289 #[test]
290 fn test_pandoc_from_str() {
291 assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
292 assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
293 }
294
295 #[test]
296 fn test_pandoc_name_and_display() {
297 assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
298 assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
299 }
300
301 #[test]
302 fn test_from_extension_does_not_auto_detect_pandoc() {
303 assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
305 assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
306 }
307
308 #[test]
309 fn test_is_pandoc_compatible() {
310 assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
311 assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
312
313 assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
314 assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
315 assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
316 assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
317 assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
318 }
319
320 #[test]
321 fn test_azure_devops_from_str() {
322 assert_eq!(
323 "azure_devops".parse::<MarkdownFlavor>().unwrap(),
324 MarkdownFlavor::AzureDevOps
325 );
326 assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
327 assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
328 assert_eq!(
329 "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
330 MarkdownFlavor::AzureDevOps
331 );
332 }
333
334 #[test]
335 fn test_azure_devops_display_and_round_trip() {
336 assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
337 let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
338 assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
339 }
340
341 #[test]
342 fn test_supports_colon_code_fences() {
343 assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
344 assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
345 assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
346 assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
347 assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
348 assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
349 assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
350 assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
351 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
352 }
353
354 #[test]
355 fn test_azure_devops_not_pandoc_compatible() {
356 assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
357 }
358
359 #[test]
360 fn test_display_all_variants_covers_azure_devops() {
361 let displayed = MarkdownFlavor::AzureDevOps.to_string();
362 assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
363 }
364
365 #[test]
366 fn test_myst_from_str() {
367 assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
368 assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
369 assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
370 }
371
372 #[test]
373 fn test_myst_display_and_round_trip() {
374 assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
375 let parsed: MarkdownFlavor = "myst".parse().unwrap();
376 assert_eq!(parsed, MarkdownFlavor::MyST);
377 }
378
379 #[test]
380 fn test_myst_capabilities() {
381 assert!(MarkdownFlavor::MyST.supports_myst_directives());
382 assert!(MarkdownFlavor::MyST.supports_myst_roles());
383 assert!(MarkdownFlavor::MyST.supports_myst_comments());
384 assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
385 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
386 assert!(!MarkdownFlavor::MyST.supports_jsx());
387
388 assert!(!MarkdownFlavor::Standard.supports_myst_directives());
389 assert!(!MarkdownFlavor::Standard.supports_myst_roles());
390 assert!(!MarkdownFlavor::Standard.supports_myst_comments());
391 }
392
393 #[test]
394 fn test_myst_name() {
395 assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
396 }
397}