rumdl_lib/rules/
front_matter_utils.rs1use regex::Regex;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4
5static 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
9static 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
13static 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
17static 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
21static 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
25static FRONT_MATTER_FIELD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([^:]+):\s*(.*)$").unwrap());
27
28static TOML_FIELD_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([^=]+)\s*=\s*"?([^"]*)"?$"#).unwrap());
30
31#[derive(Debug, PartialEq, Eq, Clone, Copy)]
33pub enum FrontMatterType {
34 Yaml,
36 Toml,
38 Json,
40 Malformed,
42 None,
44}
45
46pub struct FrontMatterUtils;
48
49impl FrontMatterUtils {
50 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 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 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 if let Some(captures) = FRONT_MATTER_FIELD.captures(line) {
85 let mut key = captures.get(1).unwrap().as_str().trim();
86
87 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 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 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 match line_indent.cmp(&indent_level) {
127 std::cmp::Ordering::Greater => {
128 indent_level = line_indent;
130 }
131 std::cmp::Ordering::Less => {
132 indent_level = line_indent;
134 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 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 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 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 if current_prefix.is_empty() {
172 current_prefix = stripped.to_string();
173 } else {
174 current_prefix = format!("{current_prefix}.{stripped}");
175 }
176 } else {
177 let full_key = if current_prefix.is_empty() {
179 key.to_string()
180 } else {
181 format!("{current_prefix}.{key}")
182 };
183 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 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 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 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 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 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 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 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 let yaml_content = "---\ntitle: Test\n---\nContent";
389 assert_eq!(
390 FrontMatterUtils::detect_front_matter_type(yaml_content),
391 FrontMatterType::Yaml
392 );
393
394 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
396 assert_eq!(
397 FrontMatterUtils::detect_front_matter_type(toml_content),
398 FrontMatterType::Toml
399 );
400
401 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 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 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 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 let no_fm = FrontMatterUtils::extract_front_matter("Regular content");
446 assert!(no_fm.is_empty());
447
448 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 assert!(!FrontMatterUtils::has_front_matter_field("Regular content", "title"));
463
464 assert!(!FrontMatterUtils::has_front_matter_field("--", "title"));
466 }
467
468 #[test]
469 fn test_get_front_matter_field_value() {
470 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 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 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 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 assert_eq!(
512 FrontMatterUtils::get_front_matter_field_value("Regular content", "title"),
513 None
514 );
515
516 assert_eq!(FrontMatterUtils::get_front_matter_field_value("--", "title"), None);
518 }
519
520 #[test]
521 fn test_extract_front_matter_fields() {
522 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 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 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)] 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 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
549 assert_eq!(FrontMatterUtils::get_front_matter_end_line(toml_content), 3);
550
551 assert_eq!(FrontMatterUtils::get_front_matter_end_line("Regular content"), 0);
553
554 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 assert!(fields.contains_key("title"));
573 }
575
576 #[test]
577 #[allow(clippy::disallowed_methods)] fn test_edge_cases() {
579 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 let only_delim = "---\n---";
586 assert!(FrontMatterUtils::extract_front_matter(only_delim).is_empty());
587
588 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}