Skip to main content

runx_parser/skill/
markdown.rs

1use std::sync::OnceLock;
2
3use regex::Regex;
4use runx_contracts::{JsonObject, JsonValue};
5
6use crate::ParseError;
7
8use super::{RawSkillIr, SkillQualityProfile};
9
10pub fn parse_skill_markdown(markdown: &str) -> Result<RawSkillIr, ParseError> {
11    static SKILL_FRONTMATTER_PATTERN: OnceLock<Result<Regex, String>> = OnceLock::new();
12    let pattern = match SKILL_FRONTMATTER_PATTERN.get_or_init(|| {
13        Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n?(.*)$").map_err(|error| error.to_string())
14    }) {
15        Ok(pattern) => pattern,
16        Err(message) => {
17            return Err(ParseError::InvalidDocument {
18                field: "skill".to_owned(),
19                message: message.clone(),
20            });
21        }
22    };
23    let Some(captures) = pattern.captures(markdown) else {
24        return Err(ParseError::InvalidDocument {
25            field: "skill".to_owned(),
26            message: "Skill markdown must start with YAML frontmatter delimited by ---.".to_owned(),
27        });
28    };
29    let raw_frontmatter = capture_string(&captures, 1)?;
30    let body = capture_string(&captures, 2)?;
31    let frontmatter = parse_yaml_object(
32        &raw_frontmatter,
33        "Skill frontmatter must parse to an object.",
34    )?;
35    Ok(RawSkillIr {
36        frontmatter,
37        raw_frontmatter,
38        body,
39    })
40}
41
42pub fn extract_skill_quality_profile(body: &str) -> Option<SkillQualityProfile> {
43    extract_markdown_section(body, "Quality Profile", 2).map(|content| SkillQualityProfile {
44        heading: "Quality Profile".to_owned(),
45        content,
46    })
47}
48
49fn parse_yaml_object(source: &str, object_error: &str) -> Result<JsonObject, ParseError> {
50    crate::assert_yaml_parity_subset("skill_frontmatter", source)?;
51    let parsed: JsonValue =
52        serde_norway::from_str(source).map_err(|error| ParseError::InvalidYaml {
53            field: "skill_frontmatter".to_owned(),
54            message: error.to_string(),
55        })?;
56    match parsed {
57        JsonValue::Object(object) => Ok(object),
58        _ => Err(ParseError::InvalidDocument {
59            field: "skill_frontmatter".to_owned(),
60            message: object_error.to_owned(),
61        }),
62    }
63}
64
65fn capture_string(captures: &regex::Captures<'_>, index: usize) -> Result<String, ParseError> {
66    captures
67        .get(index)
68        .map(|value| value.as_str().to_owned())
69        .ok_or_else(|| ParseError::InvalidDocument {
70            field: "skill".to_owned(),
71            message: "Skill markdown must start with YAML frontmatter delimited by ---.".to_owned(),
72        })
73}
74
75fn extract_markdown_section(body: &str, heading: &str, level: usize) -> Option<String> {
76    let heading_prefix = "#".repeat(level);
77    let boundary = "#".repeat(level + 1);
78    let lines = body.lines().collect::<Vec<_>>();
79    let start = lines.iter().position(|line| {
80        line.trim()
81            .eq_ignore_ascii_case(&format!("{heading_prefix} {heading}"))
82    })?;
83    let mut collected = Vec::new();
84    for line in lines.iter().skip(start + 1) {
85        let trimmed = line.trim_start();
86        if trimmed.starts_with('#') && !trimmed.starts_with(&boundary) {
87            break;
88        }
89        collected.push(*line);
90    }
91    let content = trim_blank_lines(&collected).join("\n").trim().to_owned();
92    if content.is_empty() {
93        None
94    } else {
95        Some(content)
96    }
97}
98
99fn trim_blank_lines<'a>(lines: &'a [&'a str]) -> Vec<&'a str> {
100    let mut start = 0;
101    let mut end = lines.len();
102    while start < end && lines[start].trim().is_empty() {
103        start += 1;
104    }
105    while end > start && lines[end - 1].trim().is_empty() {
106        end -= 1;
107    }
108    lines[start..end].to_vec()
109}