Skip to main content

rumdl_lib/rules/
front_matter_utils.rs

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