Skip to main content

code_moniker_check/scenario/
parse.rs

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