runx_parser/skill/
markdown.rs1use 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: ®ex::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}