Skip to main content

rumdl_lib/config/
flavor.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5// ============================================================================
6// Typestate markers for configuration pipeline
7// ============================================================================
8
9/// Marker type for configuration that has been loaded but not yet validated.
10/// This is the initial state after `load_with_discovery()`.
11#[derive(Debug, Clone, Copy, Default)]
12pub struct ConfigLoaded;
13
14/// Marker type for configuration that has been validated.
15/// Only validated configs can be converted to `Config`.
16#[derive(Debug, Clone, Copy, Default)]
17pub struct ConfigValidated;
18
19/// Markdown flavor/dialect enumeration
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23    /// Standard Markdown without flavor-specific adjustments
24    #[serde(rename = "standard", alias = "none", alias = "")]
25    #[default]
26    Standard,
27    /// MkDocs flavor with auto-reference support
28    #[serde(rename = "mkdocs")]
29    MkDocs,
30    /// MDX flavor with JSX and ESM support (.mdx files)
31    #[serde(rename = "mdx")]
32    MDX,
33    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
34    #[serde(rename = "quarto")]
35    Quarto,
36    /// Obsidian flavor with tag syntax support (#tagname as tags, not headings)
37    #[serde(rename = "obsidian")]
38    Obsidian,
39    /// Kramdown flavor for Jekyll sites with IAL, ALD, and extension block support
40    #[serde(rename = "kramdown")]
41    Kramdown,
42}
43
44/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
45fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
46    schemars::json_schema!({
47        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, quarto, obsidian, kramdown. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown.",
48        "type": "string",
49        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll"]
50    })
51}
52
53impl schemars::JsonSchema for MarkdownFlavor {
54    fn schema_name() -> std::borrow::Cow<'static, str> {
55        std::borrow::Cow::Borrowed("MarkdownFlavor")
56    }
57
58    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
59        markdown_flavor_schema(generator)
60    }
61}
62
63impl fmt::Display for MarkdownFlavor {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            MarkdownFlavor::Standard => write!(f, "standard"),
67            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
68            MarkdownFlavor::MDX => write!(f, "mdx"),
69            MarkdownFlavor::Quarto => write!(f, "quarto"),
70            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
71            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
72        }
73    }
74}
75
76impl FromStr for MarkdownFlavor {
77    type Err = String;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.to_lowercase().as_str() {
81            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
82            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
83            "mdx" => Ok(MarkdownFlavor::MDX),
84            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
85            "obsidian" => Ok(MarkdownFlavor::Obsidian),
86            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
87            // GFM and CommonMark are aliases for Standard since the base parser
88            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
89            // strikethrough, autolinks, etc.) which are a superset of CommonMark
90            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
91            _ => Err(format!("Unknown markdown flavor: {s}")),
92        }
93    }
94}
95
96impl MarkdownFlavor {
97    /// Detect flavor from file extension
98    pub fn from_extension(ext: &str) -> Self {
99        match ext.to_lowercase().as_str() {
100            "mdx" => Self::MDX,
101            "qmd" => Self::Quarto,
102            "rmd" => Self::Quarto,
103            "kramdown" => Self::Kramdown,
104            _ => Self::Standard,
105        }
106    }
107
108    /// Detect flavor from file path
109    pub fn from_path(path: &std::path::Path) -> Self {
110        path.extension()
111            .and_then(|e| e.to_str())
112            .map(Self::from_extension)
113            .unwrap_or(Self::Standard)
114    }
115
116    /// Check if this flavor supports ESM imports/exports (MDX-specific)
117    pub fn supports_esm_blocks(self) -> bool {
118        matches!(self, Self::MDX)
119    }
120
121    /// Check if this flavor supports JSX components (MDX-specific)
122    pub fn supports_jsx(self) -> bool {
123        matches!(self, Self::MDX)
124    }
125
126    /// Check if this flavor supports auto-references (MkDocs-specific)
127    pub fn supports_auto_references(self) -> bool {
128        matches!(self, Self::MkDocs)
129    }
130
131    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
132    pub fn supports_kramdown_syntax(self) -> bool {
133        matches!(self, Self::Kramdown)
134    }
135
136    /// Get a human-readable name for this flavor
137    pub fn name(self) -> &'static str {
138        match self {
139            Self::Standard => "Standard",
140            Self::MkDocs => "MkDocs",
141            Self::MDX => "MDX",
142            Self::Quarto => "Quarto",
143            Self::Obsidian => "Obsidian",
144            Self::Kramdown => "Kramdown",
145        }
146    }
147}
148
149/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
150pub fn normalize_key(key: &str) -> String {
151    // If the key looks like a rule name (e.g., MD013), uppercase it
152    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
153        key.to_ascii_uppercase()
154    } else {
155        key.replace('_', "-").to_ascii_lowercase()
156    }
157}
158
159/// Warns if a per-file-ignores pattern contains a comma but no braces.
160/// This is a common mistake where users expect "A.md,B.md" to match both files,
161/// but glob syntax requires "{A.md,B.md}" for brace expansion.
162pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
163    if pattern.contains(',') && !pattern.contains('{') {
164        eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
165        eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
166        eprintln!("  Or use separate entries for each file.");
167    }
168}