Skip to main content

rumdl_lib/rules/
front_matter_utils.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4
5// Front matter field pattern
6static FRONT_MATTER_FIELD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([^:]+):\s*(.*)$").unwrap());
7
8// TOML field pattern
9static TOML_FIELD_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([^=]+)\s*=\s*"?([^"]*)"?$"#).unwrap());
10
11/// Represents the type of front matter found in a document
12#[derive(Debug, PartialEq, Eq, Clone, Copy)]
13pub enum FrontMatterType {
14    /// YAML front matter (---)
15    Yaml,
16    /// TOML front matter (+++)
17    Toml,
18    /// JSON front matter ({})
19    Json,
20    /// Malformed front matter
21    Malformed,
22    /// No front matter
23    None,
24}
25
26/// Utility functions for detecting and handling front matter in Markdown documents
27pub struct FrontMatterUtils;
28
29impl FrontMatterUtils {
30    /// Check if a content contains front matter with a specific field
31    pub fn has_front_matter_field(content: &str, field_prefix: &str) -> bool {
32        let field_name = field_prefix.trim_end_matches(':');
33        Self::get_front_matter_field_value(content, field_name).is_some()
34    }
35
36    /// Get the value of a specific front matter field
37    pub fn get_front_matter_field_value<'a>(content: &'a str, field_name: &str) -> Option<&'a str> {
38        content.lines().nth(2)?;
39
40        let front_matter_type = Self::detect_front_matter_type(content);
41        if front_matter_type == FrontMatterType::None {
42            return None;
43        }
44
45        let front_matter = Self::extract_front_matter(content);
46        for line in front_matter {
47            let line = line.trim();
48            match front_matter_type {
49                FrontMatterType::Toml => {
50                    // Handle TOML-style fields (key = value)
51                    if let Some(captures) = TOML_FIELD_PATTERN.captures(line) {
52                        let key = captures.get(1).unwrap().as_str().trim();
53                        if key == field_name {
54                            let value = captures.get(2).unwrap().as_str();
55                            return Some(value);
56                        }
57                    }
58                }
59                _ => {
60                    // Handle YAML/JSON-style fields (key: value)
61                    if let Some(captures) = FRONT_MATTER_FIELD.captures(line) {
62                        let mut key = captures.get(1).unwrap().as_str().trim();
63
64                        // Strip quotes from the key if present (for JSON-style fields in any format)
65                        if key.starts_with('"') && key.ends_with('"') && key.len() >= 2 {
66                            key = &key[1..key.len() - 1];
67                        }
68
69                        if key == field_name {
70                            let value = captures.get(2).unwrap().as_str().trim();
71                            // Strip quotes if present
72                            if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
73                                return Some(&value[1..value.len() - 1]);
74                            }
75                            return Some(value);
76                        }
77                    }
78                }
79            }
80        }
81
82        None
83    }
84
85    /// Extract all front matter fields as a HashMap
86    pub fn extract_front_matter_fields(content: &str) -> HashMap<String, String> {
87        let mut fields = HashMap::new();
88
89        let front_matter_type = Self::detect_front_matter_type(content);
90        if front_matter_type == FrontMatterType::None {
91            return fields;
92        }
93
94        let front_matter = Self::extract_front_matter(content);
95        let mut current_prefix = String::new();
96        let mut indent_level = 0;
97
98        for line in front_matter {
99            let line_indent = line.chars().take_while(|c| c.is_whitespace()).count();
100            let line = line.trim();
101
102            // Handle indentation changes for nested fields
103            match line_indent.cmp(&indent_level) {
104                std::cmp::Ordering::Greater => {
105                    // Going deeper
106                    indent_level = line_indent;
107                }
108                std::cmp::Ordering::Less => {
109                    // Going back up
110                    indent_level = line_indent;
111                    // Remove last nested level from prefix
112                    if let Some(last_dot) = current_prefix.rfind('.') {
113                        current_prefix.truncate(last_dot);
114                    } else {
115                        current_prefix.clear();
116                    }
117                }
118                std::cmp::Ordering::Equal => {}
119            }
120
121            match front_matter_type {
122                FrontMatterType::Toml => {
123                    // Handle TOML-style fields
124                    if let Some(captures) = TOML_FIELD_PATTERN.captures(line) {
125                        let key = captures.get(1).unwrap().as_str().trim();
126                        let value = captures.get(2).unwrap().as_str();
127                        let full_key = if current_prefix.is_empty() {
128                            key.to_string()
129                        } else {
130                            format!("{current_prefix}.{key}")
131                        };
132                        fields.insert(full_key, value.to_string());
133                    }
134                }
135                _ => {
136                    // Handle YAML/JSON-style fields
137                    if let Some(captures) = FRONT_MATTER_FIELD.captures(line) {
138                        let mut key = captures.get(1).unwrap().as_str().trim();
139                        let value = captures.get(2).unwrap().as_str().trim();
140
141                        // Strip quotes from the key if present (for JSON-style fields in any format)
142                        if key.starts_with('"') && key.ends_with('"') && key.len() >= 2 {
143                            key = &key[1..key.len() - 1];
144                        }
145
146                        if let Some(stripped) = key.strip_suffix(':') {
147                            // This is a nested field marker
148                            if current_prefix.is_empty() {
149                                current_prefix = stripped.to_string();
150                            } else {
151                                current_prefix = format!("{current_prefix}.{stripped}");
152                            }
153                        } else {
154                            // This is a field with a value
155                            let full_key = if current_prefix.is_empty() {
156                                key.to_string()
157                            } else {
158                                format!("{current_prefix}.{key}")
159                            };
160                            // Strip quotes if present
161                            let value = value
162                                .strip_prefix('"')
163                                .and_then(|v| v.strip_suffix('"'))
164                                .unwrap_or(value);
165                            fields.insert(full_key, value.to_string());
166                        }
167                    }
168                }
169            }
170        }
171
172        fields
173    }
174
175    /// Extract the front matter content as a vector of lines.
176    pub fn extract_front_matter(content: &str) -> Vec<&str> {
177        if content.lines().nth(2).is_none() {
178            return Vec::new();
179        }
180        Self::front_matter_boundary(content)
181            .map_or_else(Vec::new, |(_, end)| content.lines().skip(1).take(end - 2).collect())
182    }
183
184    /// Detect the type of front matter in the content.
185    pub fn detect_front_matter_type(content: &str) -> FrontMatterType {
186        Self::front_matter_boundary(content).map_or(FrontMatterType::None, |(kind, _)| kind)
187    }
188
189    /// Get the line number where front matter ends (or 0 if no front matter).
190    ///
191    /// `LintContext::new` caches this result; downstream users must read
192    /// `ctx.front_matter_end_line()` instead (enforced in clippy.toml).
193    pub fn get_front_matter_end_line(content: &str) -> usize {
194        if content.lines().nth(2).is_none() {
195            return 0;
196        }
197        Self::front_matter_boundary(content).map_or(0, |(_, end)| end)
198    }
199
200    /// Only the first line can open front matter. Stop at its closing delimiter
201    /// instead of splitting or scanning the Markdown body on every lookup.
202    pub(crate) fn front_matter_boundary(content: &str) -> Option<(FrontMatterType, usize)> {
203        let mut lines = content.lines();
204        let first = lines.next()?;
205        let kind = if Self::is_delimiter(first, "---") {
206            FrontMatterType::Yaml
207        } else if Self::is_delimiter(first, "+++") {
208            FrontMatterType::Toml
209        } else if Self::is_delimiter(first, "{") {
210            FrontMatterType::Json
211        } else if Self::is_delimiter(first, "- --") || Self::is_delimiter(first, "-- -") {
212            FrontMatterType::Malformed
213        } else {
214            return None;
215        };
216        for (index, line) in lines.enumerate() {
217            let closes = match kind {
218                FrontMatterType::Yaml => Self::is_delimiter(line, "---"),
219                FrontMatterType::Toml => Self::is_delimiter(line, "+++"),
220                FrontMatterType::Json => Self::is_delimiter(line, "}"),
221                FrontMatterType::Malformed => Self::is_delimiter(line, "- --") || Self::is_delimiter(line, "-- -"),
222                FrontMatterType::None => unreachable!(),
223            };
224            if closes {
225                return Some((kind, index + 2));
226            }
227        }
228        None
229    }
230
231    fn is_delimiter(line: &str, marker: &str) -> bool {
232        line.strip_prefix(marker)
233            .is_some_and(|tail| tail.chars().all(char::is_whitespace))
234    }
235
236    /// Byte position of `separator` outside a leading quoted key: a quoted
237    /// key (`"og:title":`, `"a=b" =`) may contain the separator character,
238    /// so the search starts after the closing quote.
239    pub fn separator_pos_outside_quoted_key(line: &str, separator: char) -> Option<usize> {
240        let after_quote = if let Some(rest) = line.strip_prefix('"') {
241            rest.find('"').map(|i| i + 2)
242        } else if let Some(rest) = line.strip_prefix('\'') {
243            rest.find('\'').map(|i| i + 2)
244        } else {
245            None
246        };
247        match after_quote {
248            Some(start) => line[start..].find(separator).map(|i| start + i),
249            None => line.find(separator),
250        }
251    }
252
253    /// The top-level key a raw TOML key expression defines. A quoted key is
254    /// atomic (`"a.b"` defines `a.b`); otherwise the root of a dotted path
255    /// (`params.seo` defines `params`).
256    pub fn toml_root_key(raw: &str) -> &str {
257        if let Some(rest) = raw.strip_prefix('"') {
258            if let Some(end) = rest.find('"') {
259                return &rest[..end];
260            }
261        } else if let Some(rest) = raw.strip_prefix('\'')
262            && let Some(end) = rest.find('\'')
263        {
264            return &rest[..end];
265        }
266        raw.split('.').next().unwrap_or(raw).trim()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn streaming_boundaries_match_legacy_unicode_delimiters() {
276        let markers = [
277            ("---", FrontMatterType::Yaml),
278            ("+++", FrontMatterType::Toml),
279            ("{", FrontMatterType::Json),
280            ("- --", FrontMatterType::Malformed),
281            ("-- -", FrontMatterType::Malformed),
282        ];
283        for (marker, kind) in markers {
284            let legacy_open = Regex::new(&format!(r"^{}\s*$", regex::escape(marker))).unwrap();
285            for suffix in [
286                "", " ", "\t", "\r", "\u{0085}", "\u{00a0}", "\u{2028}", "\u{200b}", "\u{feff}", "x", " -",
287            ] {
288                let opening = format!("{marker}{suffix}");
289                assert_eq!(
290                    FrontMatterUtils::is_delimiter(&opening, marker),
291                    legacy_open.is_match(&opening)
292                );
293                for closing in ["---", "+++", "}", "- --", "-- -", "none"] {
294                    for tail in ["", "\n", "\nbody\n", "\r\nbody\r\n"] {
295                        let source = format!("{opening}\nkey: value\n{closing}{tail}");
296                        let expected = if legacy_open.is_match(&opening)
297                            && match kind {
298                                FrontMatterType::Yaml => closing == "---",
299                                FrontMatterType::Toml => closing == "+++",
300                                FrontMatterType::Json => closing == "}",
301                                FrontMatterType::Malformed => matches!(closing, "- --" | "-- -"),
302                                FrontMatterType::None => false,
303                            } {
304                            kind
305                        } else {
306                            FrontMatterType::None
307                        };
308                        assert_eq!(
309                            FrontMatterUtils::detect_front_matter_type(&source),
310                            expected,
311                            "{source:?}"
312                        );
313                        let fields = FrontMatterUtils::extract_front_matter(&source);
314                        assert_eq!(
315                            fields,
316                            if expected == FrontMatterType::None {
317                                vec![]
318                            } else {
319                                vec!["key: value"]
320                            },
321                            "{source:?}"
322                        );
323                    }
324                }
325            }
326        }
327    }
328
329    #[test]
330    fn test_front_matter_type_enum() {
331        assert_eq!(FrontMatterType::Yaml, FrontMatterType::Yaml);
332        assert_eq!(FrontMatterType::Toml, FrontMatterType::Toml);
333        assert_eq!(FrontMatterType::Json, FrontMatterType::Json);
334        assert_eq!(FrontMatterType::Malformed, FrontMatterType::Malformed);
335        assert_eq!(FrontMatterType::None, FrontMatterType::None);
336        assert_ne!(FrontMatterType::Yaml, FrontMatterType::Toml);
337    }
338
339    #[test]
340    fn test_detect_front_matter_type() {
341        // YAML front matter
342        let yaml_content = "---\ntitle: Test\n---\nContent";
343        assert_eq!(
344            FrontMatterUtils::detect_front_matter_type(yaml_content),
345            FrontMatterType::Yaml
346        );
347
348        // TOML front matter
349        let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
350        assert_eq!(
351            FrontMatterUtils::detect_front_matter_type(toml_content),
352            FrontMatterType::Toml
353        );
354
355        // JSON front matter
356        let json_content = "{\n\"title\": \"Test\"\n}\nContent";
357        assert_eq!(
358            FrontMatterUtils::detect_front_matter_type(json_content),
359            FrontMatterType::Json
360        );
361
362        // Malformed front matter
363        let malformed1 = "- --\ntitle: Test\n- --\nContent";
364        assert_eq!(
365            FrontMatterUtils::detect_front_matter_type(malformed1),
366            FrontMatterType::Malformed
367        );
368
369        let malformed2 = "-- -\ntitle: Test\n-- -\nContent";
370        assert_eq!(
371            FrontMatterUtils::detect_front_matter_type(malformed2),
372            FrontMatterType::Malformed
373        );
374
375        // No front matter
376        assert_eq!(
377            FrontMatterUtils::detect_front_matter_type("# Regular content"),
378            FrontMatterType::None
379        );
380        assert_eq!(FrontMatterUtils::detect_front_matter_type(""), FrontMatterType::None);
381
382        // Incomplete front matter (no closing marker)
383        assert_eq!(
384            FrontMatterUtils::detect_front_matter_type("---\ntitle: Test"),
385            FrontMatterType::None
386        );
387    }
388
389    #[test]
390    fn test_extract_front_matter() {
391        let content = "---\ntitle: Test\nauthor: Me\n---\nContent";
392        let front_matter = FrontMatterUtils::extract_front_matter(content);
393
394        assert_eq!(front_matter.len(), 2);
395        assert_eq!(front_matter[0], "title: Test");
396        assert_eq!(front_matter[1], "author: Me");
397
398        // No front matter
399        let no_fm = FrontMatterUtils::extract_front_matter("Regular content");
400        assert!(no_fm.is_empty());
401
402        // Too short content
403        let short = FrontMatterUtils::extract_front_matter("---\n---");
404        assert!(short.is_empty());
405    }
406
407    #[test]
408    fn test_has_front_matter_field() {
409        let content = "---\ntitle: Test\nauthor: Me\n---\nContent";
410
411        assert!(FrontMatterUtils::has_front_matter_field(content, "title"));
412        assert!(FrontMatterUtils::has_front_matter_field(content, "author"));
413        assert!(!FrontMatterUtils::has_front_matter_field(content, "date"));
414
415        // No front matter
416        assert!(!FrontMatterUtils::has_front_matter_field("Regular content", "title"));
417
418        // Too short content
419        assert!(!FrontMatterUtils::has_front_matter_field("--", "title"));
420    }
421
422    #[test]
423    fn test_get_front_matter_field_value() {
424        // YAML front matter
425        let yaml_content = "---\ntitle: Test Title\nauthor: \"John Doe\"\n---\nContent";
426        assert_eq!(
427            FrontMatterUtils::get_front_matter_field_value(yaml_content, "title"),
428            Some("Test Title")
429        );
430        assert_eq!(
431            FrontMatterUtils::get_front_matter_field_value(yaml_content, "author"),
432            Some("John Doe")
433        );
434        assert_eq!(
435            FrontMatterUtils::get_front_matter_field_value(yaml_content, "nonexistent"),
436            None
437        );
438
439        // TOML front matter
440        let toml_content = "+++\ntitle = \"Test Title\"\nauthor = \"John Doe\"\n+++\nContent";
441        assert_eq!(
442            FrontMatterUtils::get_front_matter_field_value(toml_content, "title"),
443            Some("Test Title")
444        );
445        assert_eq!(
446            FrontMatterUtils::get_front_matter_field_value(toml_content, "author"),
447            Some("John Doe")
448        );
449
450        // JSON-style fields in YAML front matter - keys should not include quotes
451        let json_style_yaml = "---\n\"title\": \"Test Title\"\n---\nContent";
452        assert_eq!(
453            FrontMatterUtils::get_front_matter_field_value(json_style_yaml, "title"),
454            Some("Test Title")
455        );
456
457        // Actual JSON front matter
458        let json_fm = "{\n\"title\": \"Test Title\"\n}\nContent";
459        assert_eq!(
460            FrontMatterUtils::get_front_matter_field_value(json_fm, "title"),
461            Some("Test Title")
462        );
463
464        // No front matter
465        assert_eq!(
466            FrontMatterUtils::get_front_matter_field_value("Regular content", "title"),
467            None
468        );
469
470        // Too short content
471        assert_eq!(FrontMatterUtils::get_front_matter_field_value("--", "title"), None);
472    }
473
474    #[test]
475    fn test_extract_front_matter_fields() {
476        // Simple YAML front matter
477        let yaml_content = "---\ntitle: Test\nauthor: Me\n---\nContent";
478        let fields = FrontMatterUtils::extract_front_matter_fields(yaml_content);
479
480        assert_eq!(fields.get("title"), Some(&"Test".to_string()));
481        assert_eq!(fields.get("author"), Some(&"Me".to_string()));
482
483        // TOML front matter
484        let toml_content = "+++\ntitle = \"Test\"\nauthor = \"Me\"\n+++\nContent";
485        let toml_fields = FrontMatterUtils::extract_front_matter_fields(toml_content);
486
487        assert_eq!(toml_fields.get("title"), Some(&"Test".to_string()));
488        assert_eq!(toml_fields.get("author"), Some(&"Me".to_string()));
489
490        // No front matter
491        let no_fields = FrontMatterUtils::extract_front_matter_fields("Regular content");
492        assert!(no_fields.is_empty());
493    }
494
495    #[test]
496    #[allow(clippy::disallowed_methods)] // unit test of the scanner itself
497    fn test_get_front_matter_end_line() {
498        let content = "---\ntitle: Test\n---\nContent";
499        assert_eq!(FrontMatterUtils::get_front_matter_end_line(content), 3);
500
501        // TOML
502        let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
503        assert_eq!(FrontMatterUtils::get_front_matter_end_line(toml_content), 3);
504
505        // No front matter
506        assert_eq!(FrontMatterUtils::get_front_matter_end_line("Regular content"), 0);
507
508        // Too short
509        assert_eq!(FrontMatterUtils::get_front_matter_end_line("--"), 0);
510    }
511
512    #[test]
513    fn test_nested_yaml_fields() {
514        let content = "---
515title: Test
516author:
517  name: John Doe
518  email: john@example.com
519---
520Content";
521
522        let fields = FrontMatterUtils::extract_front_matter_fields(content);
523
524        // Note: The current implementation doesn't fully handle nested YAML
525        // This test documents the current behavior
526        assert!(fields.contains_key("title"));
527        // Nested fields handling would need enhancement
528    }
529
530    #[test]
531    #[allow(clippy::disallowed_methods)] // unit test of the scanner itself
532    fn test_edge_cases() {
533        // Empty content
534        assert_eq!(FrontMatterUtils::detect_front_matter_type(""), FrontMatterType::None);
535        assert!(FrontMatterUtils::extract_front_matter("").is_empty());
536        assert_eq!(FrontMatterUtils::get_front_matter_end_line(""), 0);
537
538        // Only delimiters
539        let only_delim = "---\n---";
540        assert!(FrontMatterUtils::extract_front_matter(only_delim).is_empty());
541
542        // Multiple front matter sections (only first should be detected)
543        let multiple = "---\ntitle: First\n---\n---\ntitle: Second\n---";
544        let fm_type = FrontMatterUtils::detect_front_matter_type(multiple);
545        assert_eq!(fm_type, FrontMatterType::Yaml);
546        let fields = FrontMatterUtils::extract_front_matter_fields(multiple);
547        assert_eq!(fields.get("title"), Some(&"First".to_string()));
548    }
549
550    #[test]
551    fn test_unicode_content() {
552        let content = "---\ntitle: 你好世界\nauthor: José\n---\nContent";
553
554        assert_eq!(
555            FrontMatterUtils::detect_front_matter_type(content),
556            FrontMatterType::Yaml
557        );
558        assert_eq!(
559            FrontMatterUtils::get_front_matter_field_value(content, "title"),
560            Some("你好世界")
561        );
562        assert_eq!(
563            FrontMatterUtils::get_front_matter_field_value(content, "author"),
564            Some("José")
565        );
566    }
567}