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