code_moniker_check/scenario/
parse.rs1use super::expect::ExpectedViolation;
2use super::{Scenario, ScenarioFile, ScenarioMeta, UndemonstratedRule};
3
4#[derive(Clone, Debug, Eq, PartialEq)]
5pub struct ScenarioError {
6 pub line: usize,
7 pub message: String,
8}
9
10impl std::fmt::Display for ScenarioError {
11 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12 write!(f, "scenario line {}: {}", self.line, self.message)
13 }
14}
15
16impl std::error::Error for ScenarioError {}
17
18struct Line<'a> {
19 no: usize,
20 start: usize,
21 text: &'a str,
22}
23
24enum Block<'a> {
25 Rules,
26 Expect,
27 File { path: &'a str, fence: &'a str },
28 Ignored,
29}
30
31pub(super) fn parse_document(document: &str) -> Result<Scenario, ScenarioError> {
32 let lines = split_lines(document);
33 let mut scenario = Scenario {
34 meta: ScenarioMeta::default(),
35 rules: None,
36 files: Vec::new(),
37 expects: Vec::new(),
38 undemonstrated: Vec::new(),
39 expect_span: None,
40 };
41 let mut cursor = parse_front_matter(&lines, &mut scenario.meta)?;
42 while cursor < lines.len() {
43 let line = &lines[cursor];
44 let Some(fence) = fence_length(line.text) else {
45 cursor += 1;
46 continue;
47 };
48 let close = closing_fence(&lines, cursor + 1, fence).ok_or_else(|| ScenarioError {
49 line: line.no,
50 message: "unterminated code fence".to_string(),
51 })?;
52 let span = content_span(&lines, cursor, close);
53 collect_block(document, &mut scenario, line, span)?;
54 cursor = close + 1;
55 }
56 Ok(scenario)
57}
58
59fn collect_block(
60 document: &str,
61 scenario: &mut Scenario,
62 opening: &Line<'_>,
63 span: (usize, usize),
64) -> Result<(), ScenarioError> {
65 let content = &document[span.0..span.1];
66 match classify_info_string(opening.text) {
67 Block::Rules => {
68 if scenario.rules.is_some() {
69 return Err(block_error(opening, "duplicate cm:rules block"));
70 }
71 scenario.rules = Some(content.to_string());
72 }
73 Block::Expect => {
74 if scenario.expect_span.is_some() {
75 return Err(block_error(opening, "duplicate cm:expect block"));
76 }
77 scenario.expect_span = Some(span);
78 (scenario.expects, scenario.undemonstrated) = parse_expect_block(content, opening.no)?;
79 }
80 Block::File { path, fence } => {
81 validate_relative_path(path, opening.no)?;
82 if scenario.files.iter().any(|file| file.path == path) {
83 return Err(block_error(opening, &format!("duplicate file `{path}`")));
84 }
85 scenario.files.push(ScenarioFile {
86 path: path.to_string(),
87 fence: fence.to_string(),
88 body: content.to_string(),
89 });
90 }
91 Block::Ignored => {}
92 }
93 Ok(())
94}
95
96fn split_lines(document: &str) -> Vec<Line<'_>> {
97 let mut lines = Vec::new();
98 let mut start = 0;
99 for (no, text) in document.split_inclusive('\n').enumerate() {
100 lines.push(Line {
101 no: no + 1,
102 start,
103 text: text.trim_end_matches(['\n', '\r']),
104 });
105 start += text.len();
106 }
107 lines
108}
109
110fn parse_front_matter(lines: &[Line<'_>], meta: &mut ScenarioMeta) -> Result<usize, ScenarioError> {
111 if lines.first().is_none_or(|line| line.text.trim() != "---") {
112 return Ok(0);
113 }
114 let close = lines
115 .iter()
116 .skip(1)
117 .position(|line| line.text.trim() == "---")
118 .ok_or_else(|| ScenarioError {
119 line: 1,
120 message: "unterminated front matter".to_string(),
121 })?;
122 for line in &lines[1..close + 1] {
123 parse_meta_line(line, meta)?;
124 }
125 Ok(close + 2)
126}
127
128fn parse_meta_line(line: &Line<'_>, meta: &mut ScenarioMeta) -> Result<(), ScenarioError> {
129 let text = line.text.trim();
130 if text.is_empty() || text.starts_with('#') {
131 return Ok(());
132 }
133 let (key, value) = text.split_once(':').ok_or_else(|| ScenarioError {
134 line: line.no,
135 message: format!("expected `key: value` in front matter, got `{text}`"),
136 })?;
137 let value = value.trim();
138 match key.trim() {
139 "name" => meta.name = value.to_string(),
140 "title" => meta.title = value.to_string(),
141 "lang" => meta.lang = value.to_string(),
142 "blurb" => meta.blurb = value.to_string(),
143 "summary" => meta.summary = value.to_string(),
144 "published" => meta.published = parse_bool(value, line)?,
145 "default_rules" => meta.default_rules = Some(parse_bool(value, line)?),
146 key => {
147 return Err(ScenarioError {
148 line: line.no,
149 message: format!("unknown front matter key `{key}`"),
150 });
151 }
152 }
153 Ok(())
154}
155
156fn parse_bool(value: &str, line: &Line<'_>) -> Result<bool, ScenarioError> {
157 match value {
158 "true" => Ok(true),
159 "false" => Ok(false),
160 value => Err(ScenarioError {
161 line: line.no,
162 message: format!("expected `true` or `false`, got `{value}`"),
163 }),
164 }
165}
166
167fn fence_length(text: &str) -> Option<usize> {
168 let length = text.bytes().take_while(|byte| *byte == b'`').count();
169 (length >= 3).then_some(length)
170}
171
172fn closing_fence(lines: &[Line<'_>], from: usize, fence: usize) -> Option<usize> {
173 lines[from..]
174 .iter()
175 .position(|line| {
176 fence_length(line.text).is_some_and(|length| length >= fence)
177 && line.text.trim_end().trim_matches('`').is_empty()
178 })
179 .map(|offset| from + offset)
180}
181
182fn content_span(lines: &[Line<'_>], opening: usize, closing: usize) -> (usize, usize) {
183 if opening + 1 >= closing {
184 return (lines[closing].start, lines[closing].start);
185 }
186 (lines[opening + 1].start, lines[closing].start)
187}
188
189fn classify_info_string(text: &str) -> Block<'_> {
190 let info = text.trim_start_matches('`').trim();
191 let fence = info
192 .split_whitespace()
193 .find(|token| !token.starts_with("cm:"))
194 .unwrap_or("");
195 for token in info.split_whitespace() {
196 if token == "cm:rules" {
197 return Block::Rules;
198 }
199 if token == "cm:expect" {
200 return Block::Expect;
201 }
202 if let Some(path) = token.strip_prefix("cm:file=") {
203 return Block::File { path, fence };
204 }
205 }
206 Block::Ignored
207}
208
209fn parse_expect_block(
210 content: &str,
211 opening_line: usize,
212) -> Result<(Vec<ExpectedViolation>, Vec<UndemonstratedRule>), ScenarioError> {
213 let mut expects = Vec::new();
214 let mut undemonstrated = Vec::new();
215 for (offset, line) in content.lines().enumerate() {
216 let text = line.trim();
217 if text.is_empty() || text.starts_with('#') {
218 continue;
219 }
220 let line_no = opening_line + offset + 1;
221 if let Some(directive) = text.strip_prefix('!') {
222 undemonstrated.push(parse_undemonstrated(directive, line_no)?);
223 continue;
224 }
225 let expected = ExpectedViolation::parse(text).map_err(|message| ScenarioError {
226 line: line_no,
227 message,
228 })?;
229 expects.push(expected);
230 }
231 expects.sort();
232 undemonstrated.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
233 Ok((expects, undemonstrated))
234}
235
236fn parse_undemonstrated(directive: &str, line: usize) -> Result<UndemonstratedRule, ScenarioError> {
237 let (rule_id, reason) = directive
238 .trim()
239 .split_once(char::is_whitespace)
240 .unwrap_or((directive.trim(), ""));
241 if rule_id.is_empty() || reason.trim().is_empty() {
242 return Err(ScenarioError {
243 line,
244 message: "expected `! <rule-id> <reason>` for an undemonstrated rule".to_string(),
245 });
246 }
247 Ok(UndemonstratedRule {
248 rule_id: rule_id.to_string(),
249 reason: reason.trim().to_string(),
250 })
251}
252
253fn validate_relative_path(path: &str, line: usize) -> Result<(), ScenarioError> {
254 let invalid = path.is_empty()
255 || path.starts_with('/')
256 || path.contains('\\')
257 || path.contains(':')
258 || path
259 .split('/')
260 .any(|component| matches!(component, "" | "." | ".."));
261 if invalid {
262 return Err(ScenarioError {
263 line,
264 message: format!("`{path}` must be a clean relative path (no `..`, `.`, or absolute)"),
265 });
266 }
267 Ok(())
268}
269
270fn block_error(opening: &Line<'_>, message: &str) -> ScenarioError {
271 ScenarioError {
272 line: opening.no,
273 message: message.to_string(),
274 }
275}