code-moniker-check 0.7.1

Rules engine for code-moniker: DSL, rule config/profiles, evaluation over the symbol graph, and suppression.
Documentation
use super::expect::ExpectedViolation;
use super::{ExpectedRuleVerdict, Scenario, ScenarioFile, ScenarioMeta, UndemonstratedRule};
use crate::RuleVerdict;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScenarioError {
	pub line: usize,
	pub message: String,
}

impl std::fmt::Display for ScenarioError {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		write!(f, "scenario line {}: {}", self.line, self.message)
	}
}

impl std::error::Error for ScenarioError {}

struct Line<'a> {
	no: usize,
	start: usize,
	text: &'a str,
}

enum Block<'a> {
	Rules,
	Expect,
	File { path: &'a str, fence: &'a str },
	Ignored,
}

#[derive(Default)]
struct ParsedExpectations {
	violations: Vec<ExpectedViolation>,
	verdicts: Vec<ExpectedRuleVerdict>,
	undemonstrated: Vec<UndemonstratedRule>,
}

pub(super) fn parse_document(document: &str) -> Result<Scenario, ScenarioError> {
	let lines = split_lines(document);
	let mut scenario = Scenario {
		meta: ScenarioMeta::default(),
		rules: None,
		files: Vec::new(),
		expects: Vec::new(),
		verdicts: Vec::new(),
		undemonstrated: Vec::new(),
		expect_span: None,
	};
	let mut cursor = parse_front_matter(&lines, &mut scenario.meta)?;
	while cursor < lines.len() {
		let line = &lines[cursor];
		let Some(fence) = fence_length(line.text) else {
			cursor += 1;
			continue;
		};
		let close = closing_fence(&lines, cursor + 1, fence).ok_or_else(|| ScenarioError {
			line: line.no,
			message: "unterminated code fence".to_string(),
		})?;
		let span = content_span(&lines, cursor, close);
		collect_block(document, &mut scenario, line, span)?;
		cursor = close + 1;
	}
	Ok(scenario)
}

fn collect_block(
	document: &str,
	scenario: &mut Scenario,
	opening: &Line<'_>,
	span: (usize, usize),
) -> Result<(), ScenarioError> {
	let content = &document[span.0..span.1];
	match classify_info_string(opening.text) {
		Block::Rules => {
			if scenario.rules.is_some() {
				return Err(block_error(opening, "duplicate cm:rules block"));
			}
			scenario.rules = Some(content.to_string());
		}
		Block::Expect => {
			if scenario.expect_span.is_some() {
				return Err(block_error(opening, "duplicate cm:expect block"));
			}
			scenario.expect_span = Some(span);
			let parsed = parse_expect_block(content, opening.no)?;
			scenario.expects = parsed.violations;
			scenario.verdicts = parsed.verdicts;
			scenario.undemonstrated = parsed.undemonstrated;
		}
		Block::File { path, fence } => {
			validate_relative_path(path, opening.no)?;
			if scenario.files.iter().any(|file| file.path == path) {
				return Err(block_error(opening, &format!("duplicate file `{path}`")));
			}
			scenario.files.push(ScenarioFile {
				path: path.to_string(),
				fence: fence.to_string(),
				body: content.to_string(),
			});
		}
		Block::Ignored => {}
	}
	Ok(())
}

fn split_lines(document: &str) -> Vec<Line<'_>> {
	let mut lines = Vec::new();
	let mut start = 0;
	for (no, text) in document.split_inclusive('\n').enumerate() {
		lines.push(Line {
			no: no + 1,
			start,
			text: text.trim_end_matches(['\n', '\r']),
		});
		start += text.len();
	}
	lines
}

fn parse_front_matter(lines: &[Line<'_>], meta: &mut ScenarioMeta) -> Result<usize, ScenarioError> {
	if lines.first().is_none_or(|line| line.text.trim() != "---") {
		return Ok(0);
	}
	let close = lines
		.iter()
		.skip(1)
		.position(|line| line.text.trim() == "---")
		.ok_or_else(|| ScenarioError {
			line: 1,
			message: "unterminated front matter".to_string(),
		})?;
	for line in &lines[1..close + 1] {
		parse_meta_line(line, meta)?;
	}
	Ok(close + 2)
}

fn parse_meta_line(line: &Line<'_>, meta: &mut ScenarioMeta) -> Result<(), ScenarioError> {
	let text = line.text.trim();
	if text.is_empty() || text.starts_with('#') {
		return Ok(());
	}
	let (key, value) = text.split_once(':').ok_or_else(|| ScenarioError {
		line: line.no,
		message: format!("expected `key: value` in front matter, got `{text}`"),
	})?;
	let value = value.trim();
	match key.trim() {
		"name" => meta.name = value.to_string(),
		"title" => meta.title = value.to_string(),
		"lang" => meta.lang = value.to_string(),
		"blurb" => meta.blurb = value.to_string(),
		"summary" => meta.summary = value.to_string(),
		"published" => meta.published = parse_bool(value, line)?,
		"default_rules" => meta.default_rules = Some(parse_bool(value, line)?),
		key => {
			return Err(ScenarioError {
				line: line.no,
				message: format!("unknown front matter key `{key}`"),
			});
		}
	}
	Ok(())
}

fn parse_bool(value: &str, line: &Line<'_>) -> Result<bool, ScenarioError> {
	match value {
		"true" => Ok(true),
		"false" => Ok(false),
		value => Err(ScenarioError {
			line: line.no,
			message: format!("expected `true` or `false`, got `{value}`"),
		}),
	}
}

fn fence_length(text: &str) -> Option<usize> {
	let length = text.bytes().take_while(|byte| *byte == b'`').count();
	(length >= 3).then_some(length)
}

fn closing_fence(lines: &[Line<'_>], from: usize, fence: usize) -> Option<usize> {
	lines[from..]
		.iter()
		.position(|line| {
			fence_length(line.text).is_some_and(|length| length >= fence)
				&& line.text.trim_end().trim_matches('`').is_empty()
		})
		.map(|offset| from + offset)
}

fn content_span(lines: &[Line<'_>], opening: usize, closing: usize) -> (usize, usize) {
	if opening + 1 >= closing {
		return (lines[closing].start, lines[closing].start);
	}
	(lines[opening + 1].start, lines[closing].start)
}

fn classify_info_string(text: &str) -> Block<'_> {
	let info = text.trim_start_matches('`').trim();
	let fence = info
		.split_whitespace()
		.find(|token| !token.starts_with("cm:"))
		.unwrap_or("");
	for token in info.split_whitespace() {
		if token == "cm:rules" {
			return Block::Rules;
		}
		if token == "cm:expect" {
			return Block::Expect;
		}
		if let Some(path) = token.strip_prefix("cm:file=") {
			return Block::File { path, fence };
		}
	}
	Block::Ignored
}

fn parse_expect_block(
	content: &str,
	opening_line: usize,
) -> Result<ParsedExpectations, ScenarioError> {
	let mut expects = Vec::new();
	let mut verdicts = Vec::new();
	let mut undemonstrated = Vec::new();
	for (offset, line) in content.lines().enumerate() {
		let text = line.trim();
		if text.is_empty() || text.starts_with('#') {
			continue;
		}
		let line_no = opening_line + offset + 1;
		if let Some(directive) = text.strip_prefix("verdict ") {
			let verdict = parse_verdict(directive, line_no)?;
			reject_conflicting_directive(
				&verdict.rule_id,
				undemonstrated
					.iter()
					.any(|rule: &UndemonstratedRule| rule.rule_id == verdict.rule_id),
				line_no,
			)?;
			if verdicts
				.iter()
				.any(|existing: &ExpectedRuleVerdict| existing.rule_id == verdict.rule_id)
			{
				return Err(ScenarioError {
					line: line_no,
					message: format!("duplicate verdict for rule `{}`", verdict.rule_id),
				});
			}
			verdicts.push(verdict);
			continue;
		}
		if let Some(directive) = text.strip_prefix('!') {
			let rule = parse_undemonstrated(directive, line_no)?;
			reject_conflicting_directive(
				&rule.rule_id,
				verdicts
					.iter()
					.any(|verdict| verdict.rule_id == rule.rule_id),
				line_no,
			)?;
			undemonstrated.push(rule);
			continue;
		}
		let expected = ExpectedViolation::parse(text).map_err(|message| ScenarioError {
			line: line_no,
			message,
		})?;
		expects.push(expected);
	}
	expects.sort();
	verdicts.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
	undemonstrated.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
	Ok(ParsedExpectations {
		violations: expects,
		verdicts,
		undemonstrated,
	})
}

fn reject_conflicting_directive(
	rule_id: &str,
	conflicts: bool,
	line: usize,
) -> Result<(), ScenarioError> {
	if conflicts {
		return Err(ScenarioError {
			line,
			message: format!(
				"rule `{rule_id}` cannot be both demonstrated by a verdict and undemonstrated"
			),
		});
	}
	Ok(())
}

fn parse_verdict(directive: &str, line: usize) -> Result<ExpectedRuleVerdict, ScenarioError> {
	let (rule_id, verdict) = directive.split_once('=').ok_or_else(|| ScenarioError {
		line,
		message: "expected `verdict <rule-id> = pass|fail|inconclusive`".to_string(),
	})?;
	let rule_id = rule_id.trim();
	let verdict = match verdict.trim() {
		"pass" => RuleVerdict::Pass,
		"fail" => RuleVerdict::Fail,
		"inconclusive" => RuleVerdict::Inconclusive,
		_ => {
			return Err(ScenarioError {
				line,
				message: "expected `verdict <rule-id> = pass|fail|inconclusive`".to_string(),
			});
		}
	};
	if rule_id.is_empty() || rule_id.contains(char::is_whitespace) {
		return Err(ScenarioError {
			line,
			message: "expected one rule id in `verdict <rule-id> = <verdict>`".to_string(),
		});
	}
	Ok(ExpectedRuleVerdict {
		rule_id: rule_id.to_string(),
		verdict,
	})
}

fn parse_undemonstrated(directive: &str, line: usize) -> Result<UndemonstratedRule, ScenarioError> {
	let (rule_id, reason) = directive
		.trim()
		.split_once(char::is_whitespace)
		.unwrap_or((directive.trim(), ""));
	if rule_id.is_empty() || reason.trim().is_empty() {
		return Err(ScenarioError {
			line,
			message: "expected `! <rule-id> <reason>` for an undemonstrated rule".to_string(),
		});
	}
	Ok(UndemonstratedRule {
		rule_id: rule_id.to_string(),
		reason: reason.trim().to_string(),
	})
}

fn validate_relative_path(path: &str, line: usize) -> Result<(), ScenarioError> {
	let invalid = path.is_empty()
		|| path.starts_with('/')
		|| path.contains('\\')
		|| path.contains(':')
		|| path
			.split('/')
			.any(|component| matches!(component, "" | "." | ".."));
	if invalid {
		return Err(ScenarioError {
			line,
			message: format!("`{path}` must be a clean relative path (no `..`, `.`, or absolute)"),
		});
	}
	Ok(())
}

fn block_error(opening: &Line<'_>, message: &str) -> ScenarioError {
	ScenarioError {
		line: opening.no,
		message: message.to_string(),
	}
}