Skip to main content

frontmatter_gen/
extractor.rs

1//! This module provides functionality for extracting frontmatter from content.
2//!
3//! It includes functions to extract frontmatter in various formats (YAML, TOML, JSON) from a given string content, as well as utilities to detect the format of the frontmatter.
4
5use crate::error::Error;
6use crate::types::Format;
7
8/// Extracts raw frontmatter from the content, detecting YAML, TOML, or JSON formats.
9///
10/// This function tries to extract frontmatter based on the common delimiters for
11/// YAML (`---`), TOML (`+++`), and JSON (`{}`). If frontmatter is detected, it
12/// returns the extracted frontmatter and the remaining content.
13///
14/// # Arguments
15///
16/// * `content` - The full content string that may contain frontmatter.
17///
18/// # Returns
19///
20/// A `Result` containing a tuple of two `&str` slices: the raw frontmatter and the remaining content.
21/// If no valid frontmatter format is found, it returns `Error::InvalidFormat`.
22///
23/// # Errors
24///
25/// - `Error::InvalidFormat`: When the frontmatter format is not recognized.
26/// - `Error::ExtractionError`: When there is an issue extracting frontmatter.
27///
28/// # Example
29///
30/// ```rust
31/// use frontmatter_gen::extractor::{extract_delimited_frontmatter, extract_raw_frontmatter, extract_json_frontmatter};
32/// let content = "---\ntitle: Example\n---\nContent here";
33/// let result = extract_raw_frontmatter(content).unwrap();
34/// assert_eq!(result.0, "title: Example");
35/// assert_eq!(result.1, "Content here");
36/// ```
37pub fn extract_raw_frontmatter(
38    content: &str,
39) -> Result<(&str, &str), Error> {
40    // Extract YAML frontmatter
41    if let Some(yaml) =
42        extract_delimited_frontmatter(content, "---\n", "\n---")
43            .or_else(|| {
44                extract_delimited_frontmatter(
45                    content, "---\r\n", "\r\n---",
46                )
47            })
48    {
49        let remaining = &content[content
50            .find("\n---\n")
51            .or_else(|| content.find("\r\n---\r\n"))
52            .map_or(content.len(), |i| i + 5)..];
53        return Ok((yaml, remaining.trim_start()));
54    }
55
56    // Extract TOML frontmatter
57    if let Some(toml) =
58        extract_delimited_frontmatter(content, "+++\n", "\n+++")
59            .or_else(|| {
60                extract_delimited_frontmatter(
61                    content, "+++\r\n", "\r\n+++",
62                )
63            })
64    {
65        let remaining = &content[content
66            .find("\n+++\n")
67            .or_else(|| content.find("\r\n+++\r\n"))
68            .map_or(content.len(), |i| i + 5)..];
69        return Ok((toml, remaining.trim_start()));
70    }
71
72    // Extract JSON frontmatter
73    if let Ok(json) = extract_json_frontmatter(content) {
74        let remaining = &content[json.len()..];
75        return Ok((json, remaining.trim_start()));
76    }
77
78    // Handle cases where frontmatter delimiters exist but are empty
79    if content.starts_with("---\n---")
80        || content.starts_with("+++\n+++")
81    {
82        return Err(Error::InvalidFormat);
83    }
84
85    Err(Error::InvalidFormat)
86}
87
88/// Extracts JSON frontmatter from the content by detecting balanced curly braces (`{}`).
89///
90/// This function attempts to locate a valid JSON object starting with `{` and checks for balanced
91/// curly braces to identify the end of the frontmatter. If the JSON object is found, it returns
92/// the frontmatter as a string slice. A maximum nesting depth is enforced to prevent deeply nested
93/// JSON from causing stack overflow.
94///
95/// # Arguments
96///
97/// * `content` - The full content string that may contain JSON frontmatter.
98///
99/// # Returns
100///
101/// A `Result` containing the extracted JSON frontmatter string slice.
102/// If no valid JSON frontmatter is detected, it returns an `Error`.
103///
104/// # Errors
105///
106/// - `Error::InvalidJson`: If the content does not start with `{` or contains unbalanced braces.
107/// - `Error::JsonDepthLimitExceeded`: If the JSON object exceeds the allowed nesting depth.
108///
109/// # Example
110///
111/// ```rust
112/// use frontmatter_gen::extractor::extract_json_frontmatter;
113/// let content = "{ \"title\": \"Example\" }\nContent";
114/// let frontmatter = extract_json_frontmatter(content).unwrap();
115/// assert_eq!(frontmatter, "{ \"title\": \"Example\" }");
116/// ```
117pub fn extract_json_frontmatter(content: &str) -> Result<&str, Error> {
118    const MAX_DEPTH: usize = 100; // Limit maximum nesting depth
119    let trimmed = content.trim_start();
120
121    // If the content doesn't start with '{', it's not JSON frontmatter.
122    if !trimmed.starts_with('{') {
123        return Err(Error::InvalidJson);
124    }
125
126    let mut brace_count = 0;
127    let mut depth = 0;
128    let mut in_string = false;
129    let mut escape_next = false;
130
131    // Iterate over the characters in the trimmed content, looking for balanced braces.
132    for (idx, ch) in trimmed.char_indices() {
133        if escape_next {
134            escape_next = false;
135            continue;
136        }
137
138        match ch {
139            '"' if !in_string => in_string = true,
140            '"' if in_string => in_string = false,
141            '\\' if in_string => escape_next = true,
142            '{' if !in_string => {
143                brace_count += 1;
144                depth += 1;
145                // Check if the maximum depth is exceeded
146                if depth > MAX_DEPTH {
147                    return Err(Error::JsonDepthLimitExceeded);
148                }
149            }
150            '}' if !in_string => {
151                brace_count -= 1;
152                // Decrease depth when closing braces
153                depth = depth.saturating_sub(1);
154                // Once braces are balanced (brace_count == 0), we've reached the end of the JSON object.
155                if brace_count == 0 {
156                    return Ok(&trimmed[..=idx]);
157                }
158            }
159            _ => {}
160        }
161    }
162
163    // If no balanced braces are found, return an error.
164    Err(Error::InvalidJson)
165}
166
167/// Detects the format of the extracted frontmatter.
168///
169/// This function analyzes the raw frontmatter and determines whether it is in YAML,
170/// TOML, or JSON format by examining the structure of the data.
171///
172/// # Arguments
173///
174/// * `raw_frontmatter` - The extracted frontmatter as a string slice.
175///
176/// # Returns
177///
178/// A `Result` containing the detected `Format` (either `Json`, `Toml`, or `Yaml`).
179///
180/// # Errors
181///
182/// - `Error::InvalidFormat`: If the format cannot be determined.
183///
184/// # Example
185///
186/// ```rust
187/// use frontmatter_gen::extractor::detect_format;
188/// use frontmatter_gen::Format;
189/// let raw = "---\ntitle: Example\n---";
190/// let format = detect_format(raw).unwrap();
191/// assert_eq!(format, Format::Yaml);
192/// ```
193pub fn detect_format(raw_frontmatter: &str) -> Result<Format, Error> {
194    let trimmed = raw_frontmatter.trim_start();
195
196    // Check for YAML front matter marker
197    if trimmed.starts_with("---") {
198        return Ok(Format::Yaml);
199    }
200
201    // Check for JSON structure
202    if trimmed.starts_with('{') {
203        return Ok(Format::Json);
204    }
205
206    // Check for YAML-like structure
207    if trimmed.contains(':') && !trimmed.contains('{') {
208        return Ok(Format::Yaml);
209    }
210
211    // Check for TOML-like structure
212    if trimmed.contains('=') {
213        return Ok(Format::Toml);
214    }
215
216    // Default to an error if none of the formats match
217    Err(Error::InvalidFormat)
218}
219
220/// Extracts frontmatter enclosed by the given start and end delimiters.
221///
222/// This function checks for frontmatter enclosed by delimiters like `---` for YAML or `+++` for TOML.
223/// It returns the extracted frontmatter if the delimiters are found.
224///
225/// # Arguments
226///
227/// * `content` - The full content string containing frontmatter.
228/// * `start_delim` - The starting delimiter (e.g., `---\n` for YAML).
229/// * `end_delim` - The ending delimiter (e.g., `\n---\n` for YAML).
230///
231/// # Returns
232///
233/// An `Option` containing the extracted frontmatter as a string slice. Returns `None`
234/// if the delimiters are not found.
235///
236/// # Example
237///
238/// ```rust
239/// use frontmatter_gen::extractor::extract_delimited_frontmatter;
240/// let content = "---\ntitle: Example\n---\nContent";
241/// let frontmatter = extract_delimited_frontmatter(content, "---\n", "\n---\n").unwrap();
242/// assert_eq!(frontmatter, "title: Example");
243/// ```
244#[must_use]
245pub fn extract_delimited_frontmatter<'a>(
246    content: &'a str,
247    start_delim: &str,
248    end_delim: &str,
249) -> Option<&'a str> {
250    let start_index = content.find(start_delim)? + start_delim.len();
251    let end_index = content.find(end_delim)?;
252
253    if start_index <= end_index {
254        Some(content[start_index..end_index].trim())
255    } else {
256        None
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    /// Tests for extracting raw frontmatter
265    mod extract_raw_frontmatter {
266        use super::*;
267
268        #[test]
269        fn test_extract_yaml() {
270            let content = r"---
271title: Example
272---
273Content here";
274            let result = extract_raw_frontmatter(content).unwrap();
275            assert_eq!(result.0, "title: Example");
276            assert_eq!(result.1, "Content here");
277        }
278
279        #[test]
280        fn test_extract_toml() {
281            let content = r#"+++
282title = "Example"
283+++
284Content here"#;
285            let result = extract_raw_frontmatter(content).unwrap();
286            assert_eq!(result.0, r#"title = "Example""#);
287            assert_eq!(result.1, "Content here");
288        }
289
290        #[test]
291        fn test_extract_json() {
292            let content = r#"{ "title": "Example" }
293Content here"#;
294            let result = extract_raw_frontmatter(content).unwrap();
295            assert_eq!(result.0, r#"{ "title": "Example" }"#);
296            assert_eq!(result.1, "Content here");
297        }
298
299        #[test]
300        fn test_invalid_format() {
301            let content = "Invalid frontmatter";
302            let result = detect_format(content);
303            assert!(matches!(result, Err(Error::InvalidFormat)));
304        }
305
306        #[test]
307        fn test_extract_crlf_yaml_and_toml() {
308            let yaml_crlf =
309                "---\r\ntitle: Example\r\n---\r\nContent here";
310            let (raw_yaml, body_yaml) =
311                extract_raw_frontmatter(yaml_crlf).unwrap();
312            assert_eq!(raw_yaml, "title: Example");
313            assert_eq!(body_yaml, "Content here");
314
315            let toml_crlf =
316                "+++\r\ntitle = \"Example\"\r\n+++\r\nContent here";
317            let (raw_toml, body_toml) =
318                extract_raw_frontmatter(toml_crlf).unwrap();
319            assert_eq!(raw_toml, "title = \"Example\"");
320            assert_eq!(body_toml, "Content here");
321
322            assert_eq!(
323                detect_format("---\ntitle: Example\n---").unwrap(),
324                Format::Yaml
325            );
326            assert!(matches!(
327                extract_json_frontmatter("{ \"unclosed\": 1"),
328                Err(Error::InvalidJson)
329            ));
330        }
331    }
332
333    /// Tests for JSON frontmatter extraction
334    mod extract_json_frontmatter {
335        use super::*;
336
337        #[test]
338        fn test_valid_json() {
339            let content = r#"{ "title": "Example" }
340Content here"#;
341            let result = extract_json_frontmatter(content).unwrap();
342            assert_eq!(result, r#"{ "title": "Example" }"#);
343        }
344
345        #[test]
346        fn test_nested_json() {
347            let content = r#"{ "a": { "b": { "c": { "d": { "e": {} }}}}}
348Content here"#;
349            let result = extract_json_frontmatter(content);
350            assert!(result.is_ok());
351            assert_eq!(
352                result.unwrap(),
353                r#"{ "a": { "b": { "c": { "d": { "e": {} }}}}}"#
354            );
355        }
356
357        #[test]
358        fn test_too_deep_json() {
359            let mut content = String::from("{ ");
360            for _ in 0..101 {
361                content.push_str(r#""a": { "#);
362            }
363            content.push_str(&"}".repeat(101));
364            content.push_str("\nContent here");
365
366            let result = extract_json_frontmatter(&content);
367            assert!(matches!(
368                result,
369                Err(Error::JsonDepthLimitExceeded)
370            ));
371        }
372
373        #[test]
374        fn test_escaped_characters() {
375            let content = r#"{ "title": "Example with \"quotes\" and {braces}", "content": "Some text with \\ backslash" }
376Actual content starts here"#;
377            let result = extract_json_frontmatter(content).unwrap();
378            assert_eq!(
379                result,
380                r#"{ "title": "Example with \"quotes\" and {braces}", "content": "Some text with \\ backslash" }"#
381            );
382        }
383
384        #[test]
385        fn test_invalid_json() {
386            let content = "Not a JSON frontmatter";
387            let result = extract_json_frontmatter(content);
388            assert!(matches!(result, Err(Error::InvalidJson)));
389        }
390    }
391
392    /// Tests for format detection
393    mod detect_format {
394        use super::*;
395
396        #[test]
397        fn test_yaml_format() {
398            let content = "title: Example";
399            let result = detect_format(content).unwrap();
400            assert_eq!(result, Format::Yaml);
401        }
402
403        #[test]
404        fn test_toml_format() {
405            let content = "title = \"Example\"";
406            let result = detect_format(content).unwrap();
407            assert_eq!(result, Format::Toml);
408        }
409
410        #[test]
411        fn test_json_format() {
412            let content = r#"{ "title": "Example" }"#;
413            let result = detect_format(content).unwrap();
414            assert_eq!(result, Format::Json);
415        }
416
417        #[test]
418        fn test_invalid_format() {
419            let content = "Invalid content";
420            let result = detect_format(content);
421            assert!(matches!(result, Err(Error::InvalidFormat)));
422        }
423    }
424
425    /// Tests for delimited frontmatter extraction
426    mod extract_delimited_frontmatter {
427        use super::*;
428
429        #[test]
430        fn test_valid_yaml() {
431            let content = "---\ntitle: Example\n---\nContent here";
432            let result = extract_delimited_frontmatter(
433                content, "---\n", "\n---\n",
434            )
435            .unwrap();
436            assert_eq!(result, "title: Example");
437        }
438
439        #[test]
440        fn test_valid_toml() {
441            let content = "+++\ntitle = \"Example\"\n+++\nContent here";
442            let result = extract_delimited_frontmatter(
443                content, "+++\n", "\n+++\n",
444            )
445            .unwrap();
446            assert_eq!(result, r#"title = "Example""#);
447        }
448
449        #[test]
450        fn test_valid_windows_format() {
451            let content =
452                "---\r\ntitle: Example\r\n---\r\nContent here";
453            let result = extract_delimited_frontmatter(
454                content,
455                "---\r\n",
456                "\r\n---\r\n",
457            )
458            .unwrap();
459            assert_eq!(result, "title: Example");
460        }
461
462        #[test]
463        fn test_missing_start_delimiter() {
464            let content = "title: Example\n---\nContent here";
465            let result = extract_delimited_frontmatter(
466                content, "---\n", "\n---\n",
467            );
468            assert!(result.is_none());
469        }
470
471        #[test]
472        fn test_missing_end_delimiter() {
473            let content = "---\ntitle: Example\nContent here";
474            let result = extract_delimited_frontmatter(
475                content, "---\n", "\n---\n",
476            );
477            assert!(result.is_none());
478        }
479    }
480}