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
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_front_matter_type_enum() {
377        assert_eq!(FrontMatterType::Yaml, FrontMatterType::Yaml);
378        assert_eq!(FrontMatterType::Toml, FrontMatterType::Toml);
379        assert_eq!(FrontMatterType::Json, FrontMatterType::Json);
380        assert_eq!(FrontMatterType::Malformed, FrontMatterType::Malformed);
381        assert_eq!(FrontMatterType::None, FrontMatterType::None);
382        assert_ne!(FrontMatterType::Yaml, FrontMatterType::Toml);
383    }
384
385    #[test]
386    fn test_detect_front_matter_type() {
387        // YAML front matter
388        let yaml_content = "---\ntitle: Test\n---\nContent";
389        assert_eq!(
390            FrontMatterUtils::detect_front_matter_type(yaml_content),
391            FrontMatterType::Yaml
392        );
393
394        // TOML front matter
395        let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
396        assert_eq!(
397            FrontMatterUtils::detect_front_matter_type(toml_content),
398            FrontMatterType::Toml
399        );
400
401        // JSON front matter
402        let json_content = "{\n\"title\": \"Test\"\n}\nContent";
403        assert_eq!(
404            FrontMatterUtils::detect_front_matter_type(json_content),
405            FrontMatterType::Json
406        );
407
408        // Malformed front matter
409        let malformed1 = "- --\ntitle: Test\n- --\nContent";
410        assert_eq!(
411            FrontMatterUtils::detect_front_matter_type(malformed1),
412            FrontMatterType::Malformed
413        );
414
415        let malformed2 = "-- -\ntitle: Test\n-- -\nContent";
416        assert_eq!(
417            FrontMatterUtils::detect_front_matter_type(malformed2),
418            FrontMatterType::Malformed
419        );
420
421        // No front matter
422        assert_eq!(
423            FrontMatterUtils::detect_front_matter_type("# Regular content"),
424            FrontMatterType::None
425        );
426        assert_eq!(FrontMatterUtils::detect_front_matter_type(""), FrontMatterType::None);
427
428        // Incomplete front matter (no closing marker)
429        assert_eq!(
430            FrontMatterUtils::detect_front_matter_type("---\ntitle: Test"),
431            FrontMatterType::None
432        );
433    }
434
435    #[test]
436    fn test_extract_front_matter() {
437        let content = "---\ntitle: Test\nauthor: Me\n---\nContent";
438        let front_matter = FrontMatterUtils::extract_front_matter(content);
439
440        assert_eq!(front_matter.len(), 2);
441        assert_eq!(front_matter[0], "title: Test");
442        assert_eq!(front_matter[1], "author: Me");
443
444        // No front matter
445        let no_fm = FrontMatterUtils::extract_front_matter("Regular content");
446        assert!(no_fm.is_empty());
447
448        // Too short content
449        let short = FrontMatterUtils::extract_front_matter("---\n---");
450        assert!(short.is_empty());
451    }
452
453    #[test]
454    fn test_has_front_matter_field() {
455        let content = "---\ntitle: Test\nauthor: Me\n---\nContent";
456
457        assert!(FrontMatterUtils::has_front_matter_field(content, "title"));
458        assert!(FrontMatterUtils::has_front_matter_field(content, "author"));
459        assert!(!FrontMatterUtils::has_front_matter_field(content, "date"));
460
461        // No front matter
462        assert!(!FrontMatterUtils::has_front_matter_field("Regular content", "title"));
463
464        // Too short content
465        assert!(!FrontMatterUtils::has_front_matter_field("--", "title"));
466    }
467
468    #[test]
469    fn test_get_front_matter_field_value() {
470        // YAML front matter
471        let yaml_content = "---\ntitle: Test Title\nauthor: \"John Doe\"\n---\nContent";
472        assert_eq!(
473            FrontMatterUtils::get_front_matter_field_value(yaml_content, "title"),
474            Some("Test Title")
475        );
476        assert_eq!(
477            FrontMatterUtils::get_front_matter_field_value(yaml_content, "author"),
478            Some("John Doe")
479        );
480        assert_eq!(
481            FrontMatterUtils::get_front_matter_field_value(yaml_content, "nonexistent"),
482            None
483        );
484
485        // TOML front matter
486        let toml_content = "+++\ntitle = \"Test Title\"\nauthor = \"John Doe\"\n+++\nContent";
487        assert_eq!(
488            FrontMatterUtils::get_front_matter_field_value(toml_content, "title"),
489            Some("Test Title")
490        );
491        assert_eq!(
492            FrontMatterUtils::get_front_matter_field_value(toml_content, "author"),
493            Some("John Doe")
494        );
495
496        // JSON-style fields in YAML front matter - keys should not include quotes
497        let json_style_yaml = "---\n\"title\": \"Test Title\"\n---\nContent";
498        assert_eq!(
499            FrontMatterUtils::get_front_matter_field_value(json_style_yaml, "title"),
500            Some("Test Title")
501        );
502
503        // Actual JSON front matter
504        let json_fm = "{\n\"title\": \"Test Title\"\n}\nContent";
505        assert_eq!(
506            FrontMatterUtils::get_front_matter_field_value(json_fm, "title"),
507            Some("Test Title")
508        );
509
510        // No front matter
511        assert_eq!(
512            FrontMatterUtils::get_front_matter_field_value("Regular content", "title"),
513            None
514        );
515
516        // Too short content
517        assert_eq!(FrontMatterUtils::get_front_matter_field_value("--", "title"), None);
518    }
519
520    #[test]
521    fn test_extract_front_matter_fields() {
522        // Simple YAML front matter
523        let yaml_content = "---\ntitle: Test\nauthor: Me\n---\nContent";
524        let fields = FrontMatterUtils::extract_front_matter_fields(yaml_content);
525
526        assert_eq!(fields.get("title"), Some(&"Test".to_string()));
527        assert_eq!(fields.get("author"), Some(&"Me".to_string()));
528
529        // TOML front matter
530        let toml_content = "+++\ntitle = \"Test\"\nauthor = \"Me\"\n+++\nContent";
531        let toml_fields = FrontMatterUtils::extract_front_matter_fields(toml_content);
532
533        assert_eq!(toml_fields.get("title"), Some(&"Test".to_string()));
534        assert_eq!(toml_fields.get("author"), Some(&"Me".to_string()));
535
536        // No front matter
537        let no_fields = FrontMatterUtils::extract_front_matter_fields("Regular content");
538        assert!(no_fields.is_empty());
539    }
540
541    #[test]
542    #[allow(clippy::disallowed_methods)] // unit test of the scanner itself
543    fn test_get_front_matter_end_line() {
544        let content = "---\ntitle: Test\n---\nContent";
545        assert_eq!(FrontMatterUtils::get_front_matter_end_line(content), 3);
546
547        // TOML
548        let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
549        assert_eq!(FrontMatterUtils::get_front_matter_end_line(toml_content), 3);
550
551        // No front matter
552        assert_eq!(FrontMatterUtils::get_front_matter_end_line("Regular content"), 0);
553
554        // Too short
555        assert_eq!(FrontMatterUtils::get_front_matter_end_line("--"), 0);
556    }
557
558    #[test]
559    fn test_nested_yaml_fields() {
560        let content = "---
561title: Test
562author:
563  name: John Doe
564  email: john@example.com
565---
566Content";
567
568        let fields = FrontMatterUtils::extract_front_matter_fields(content);
569
570        // Note: The current implementation doesn't fully handle nested YAML
571        // This test documents the current behavior
572        assert!(fields.contains_key("title"));
573        // Nested fields handling would need enhancement
574    }
575
576    #[test]
577    #[allow(clippy::disallowed_methods)] // unit test of the scanner itself
578    fn test_edge_cases() {
579        // Empty content
580        assert_eq!(FrontMatterUtils::detect_front_matter_type(""), FrontMatterType::None);
581        assert!(FrontMatterUtils::extract_front_matter("").is_empty());
582        assert_eq!(FrontMatterUtils::get_front_matter_end_line(""), 0);
583
584        // Only delimiters
585        let only_delim = "---\n---";
586        assert!(FrontMatterUtils::extract_front_matter(only_delim).is_empty());
587
588        // Multiple front matter sections (only first should be detected)
589        let multiple = "---\ntitle: First\n---\n---\ntitle: Second\n---";
590        let fm_type = FrontMatterUtils::detect_front_matter_type(multiple);
591        assert_eq!(fm_type, FrontMatterType::Yaml);
592        let fields = FrontMatterUtils::extract_front_matter_fields(multiple);
593        assert_eq!(fields.get("title"), Some(&"First".to_string()));
594    }
595
596    #[test]
597    fn test_unicode_content() {
598        let content = "---\ntitle: 你好世界\nauthor: José\n---\nContent";
599
600        assert_eq!(
601            FrontMatterUtils::detect_front_matter_type(content),
602            FrontMatterType::Yaml
603        );
604        assert_eq!(
605            FrontMatterUtils::get_front_matter_field_value(content, "title"),
606            Some("你好世界")
607        );
608        assert_eq!(
609            FrontMatterUtils::get_front_matter_field_value(content, "author"),
610            Some("José")
611        );
612    }
613}