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 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 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 let yaml_content = "---\ntitle: Test\n---\nContent";
422 assert_eq!(
423 FrontMatterUtils::detect_front_matter_type(yaml_content),
424 FrontMatterType::Yaml
425 );
426
427 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
429 assert_eq!(
430 FrontMatterUtils::detect_front_matter_type(toml_content),
431 FrontMatterType::Toml
432 );
433
434 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 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 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 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 let no_fm = FrontMatterUtils::extract_front_matter("Regular content");
479 assert!(no_fm.is_empty());
480
481 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 assert!(!FrontMatterUtils::has_front_matter_field("Regular content", "title"));
496
497 assert!(!FrontMatterUtils::has_front_matter_field("--", "title"));
499 }
500
501 #[test]
502 fn test_get_front_matter_field_value() {
503 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 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 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 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 assert_eq!(
545 FrontMatterUtils::get_front_matter_field_value("Regular content", "title"),
546 None
547 );
548
549 assert_eq!(FrontMatterUtils::get_front_matter_field_value("--", "title"), None);
551 }
552
553 #[test]
554 fn test_extract_front_matter_fields() {
555 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 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 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)] 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 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
582 assert_eq!(FrontMatterUtils::get_front_matter_end_line(toml_content), 3);
583
584 assert_eq!(FrontMatterUtils::get_front_matter_end_line("Regular content"), 0);
586
587 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 assert!(fields.contains_key("title"));
606 }
608
609 #[test]
610 #[allow(clippy::disallowed_methods)] fn test_edge_cases() {
612 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 let only_delim = "---\n---";
619 assert!(FrontMatterUtils::extract_front_matter(only_delim).is_empty());
620
621 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}