Skip to main content

code_moniker_check/scenario/
parse.rs

1use 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}