1use regex::Regex;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4
5static FRONT_MATTER_FIELD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([^:]+):\s*(.*)$").unwrap());
7
8static TOML_FIELD_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"^([^=]+)\s*=\s*"?([^"]*)"?$"#).unwrap());
10
11#[derive(Debug, PartialEq, Eq, Clone, Copy)]
13pub enum FrontMatterType {
14 Yaml,
16 Toml,
18 Json,
20 Malformed,
22 None,
24}
25
26pub struct FrontMatterUtils;
28
29impl FrontMatterUtils {
30 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 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 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 if let Some(captures) = FRONT_MATTER_FIELD.captures(line) {
62 let mut key = captures.get(1).unwrap().as_str().trim();
63
64 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 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 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 match line_indent.cmp(&indent_level) {
104 std::cmp::Ordering::Greater => {
105 indent_level = line_indent;
107 }
108 std::cmp::Ordering::Less => {
109 indent_level = line_indent;
111 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 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 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 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 if current_prefix.is_empty() {
149 current_prefix = stripped.to_string();
150 } else {
151 current_prefix = format!("{current_prefix}.{stripped}");
152 }
153 } else {
154 let full_key = if current_prefix.is_empty() {
156 key.to_string()
157 } else {
158 format!("{current_prefix}.{key}")
159 };
160 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 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 pub fn detect_front_matter_type(content: &str) -> FrontMatterType {
186 Self::front_matter_boundary(content).map_or(FrontMatterType::None, |(kind, _)| kind)
187 }
188
189 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 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 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 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 let yaml_content = "---\ntitle: Test\n---\nContent";
343 assert_eq!(
344 FrontMatterUtils::detect_front_matter_type(yaml_content),
345 FrontMatterType::Yaml
346 );
347
348 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
350 assert_eq!(
351 FrontMatterUtils::detect_front_matter_type(toml_content),
352 FrontMatterType::Toml
353 );
354
355 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 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 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 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 let no_fm = FrontMatterUtils::extract_front_matter("Regular content");
400 assert!(no_fm.is_empty());
401
402 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 assert!(!FrontMatterUtils::has_front_matter_field("Regular content", "title"));
417
418 assert!(!FrontMatterUtils::has_front_matter_field("--", "title"));
420 }
421
422 #[test]
423 fn test_get_front_matter_field_value() {
424 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 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 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 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 assert_eq!(
466 FrontMatterUtils::get_front_matter_field_value("Regular content", "title"),
467 None
468 );
469
470 assert_eq!(FrontMatterUtils::get_front_matter_field_value("--", "title"), None);
472 }
473
474 #[test]
475 fn test_extract_front_matter_fields() {
476 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 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 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)] 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 let toml_content = "+++\ntitle = \"Test\"\n+++\nContent";
503 assert_eq!(FrontMatterUtils::get_front_matter_end_line(toml_content), 3);
504
505 assert_eq!(FrontMatterUtils::get_front_matter_end_line("Regular content"), 0);
507
508 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 assert!(fields.contains_key("title"));
527 }
529
530 #[test]
531 #[allow(clippy::disallowed_methods)] fn test_edge_cases() {
533 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 let only_delim = "---\n---";
540 assert!(FrontMatterUtils::extract_front_matter(only_delim).is_empty());
541
542 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}