Skip to main content

foch_test/
select.rs

1//! Choosing cases: node-ID prefixes, `-k` keyword and `-m` mark expressions.
2
3use crate::model::{NodeId, TestCase};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeSet;
6
7/// A boolean expression over words: `reform and not (slow or war)`.
8#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Expr {
11	Word(String),
12	Not(Box<Expr>),
13	And(Box<Expr>, Box<Expr>),
14	Or(Box<Expr>, Box<Expr>),
15}
16
17impl Expr {
18	pub fn parse(text: &str) -> Result<Self, String> {
19		let tokens = tokenize(text);
20		let mut position = 0;
21		let expr = parse_or(&tokens, &mut position)?;
22		if position != tokens.len() {
23			return Err(format!(
24				"unexpected {:?} in expression {text:?}",
25				tokens[position]
26			));
27		}
28		Ok(expr)
29	}
30
31	pub fn eval(&self, word: &dyn Fn(&str) -> bool) -> bool {
32		match self {
33			Self::Word(value) => word(value),
34			Self::Not(inner) => !inner.eval(word),
35			Self::And(left, right) => left.eval(word) && right.eval(word),
36			Self::Or(left, right) => left.eval(word) || right.eval(word),
37		}
38	}
39}
40
41fn tokenize(text: &str) -> Vec<String> {
42	let mut tokens = Vec::new();
43	let mut current = String::new();
44	for character in text.chars() {
45		if character.is_whitespace() || matches!(character, '(' | ')') {
46			if !current.is_empty() {
47				tokens.push(std::mem::take(&mut current));
48			}
49			if !character.is_whitespace() {
50				tokens.push(character.to_string());
51			}
52		} else {
53			current.push(character);
54		}
55	}
56	if !current.is_empty() {
57		tokens.push(current);
58	}
59	tokens
60}
61
62fn parse_or(tokens: &[String], position: &mut usize) -> Result<Expr, String> {
63	let mut left = parse_and(tokens, position)?;
64	while tokens.get(*position).is_some_and(|token| token == "or") {
65		*position += 1;
66		left = Expr::Or(Box::new(left), Box::new(parse_and(tokens, position)?));
67	}
68	Ok(left)
69}
70
71fn parse_and(tokens: &[String], position: &mut usize) -> Result<Expr, String> {
72	let mut left = parse_not(tokens, position)?;
73	while tokens.get(*position).is_some_and(|token| token == "and") {
74		*position += 1;
75		left = Expr::And(Box::new(left), Box::new(parse_not(tokens, position)?));
76	}
77	Ok(left)
78}
79
80fn parse_not(tokens: &[String], position: &mut usize) -> Result<Expr, String> {
81	match tokens.get(*position).map(String::as_str) {
82		Some("not") => {
83			*position += 1;
84			Ok(Expr::Not(Box::new(parse_not(tokens, position)?)))
85		}
86		Some("(") => {
87			*position += 1;
88			let inner = parse_or(tokens, position)?;
89			if tokens.get(*position).map(String::as_str) != Some(")") {
90				return Err("missing ) in expression".into());
91			}
92			*position += 1;
93			Ok(inner)
94		}
95		Some(")" | "and" | "or") | None => Err("expected a word in expression".into()),
96		Some(word) => {
97			*position += 1;
98			Ok(Expr::Word(word.into()))
99		}
100	}
101}
102
103/// Matches a node ID or one of its prefixes at a segment boundary, with or
104/// without the leading mod name.
105#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
106#[serde(transparent)]
107pub struct NodeIdPattern(String);
108
109impl NodeIdPattern {
110	pub fn parse(text: &str) -> Result<Self, String> {
111		let text = text.trim().replace('\\', "/");
112		if text.is_empty() {
113			return Err("empty node ID pattern".into());
114		}
115		Ok(Self(text))
116	}
117
118	pub fn matches(&self, node: &NodeId) -> bool {
119		[node.to_string(), node.local()].iter().any(|candidate| {
120			candidate.strip_prefix(self.0.as_str()).is_some_and(|rest| {
121				rest.is_empty() || rest.starts_with("::") || rest.starts_with('[')
122			})
123		})
124	}
125}
126
127#[derive(Clone, Debug, Default, Serialize, Deserialize)]
128pub struct Selector {
129	/// Empty selects every case.
130	pub nodes: Vec<NodeIdPattern>,
131	/// pytest `-k`: words match case-insensitively anywhere in the node ID.
132	pub keyword: Option<Expr>,
133	/// pytest `-m`: words match labels given with `#mark`.
134	pub marks: Option<Expr>,
135	/// Node IDs to keep, such as the failures of a previous run.
136	pub only: Option<BTreeSet<String>>,
137}
138
139impl Selector {
140	pub fn matches(&self, case: &TestCase) -> bool {
141		let node = case.node.to_string();
142		(self.nodes.is_empty() || self.nodes.iter().any(|pattern| pattern.matches(&case.node)))
143			&& self.keyword.as_ref().is_none_or(|expr| {
144				let haystack = node.to_lowercase();
145				expr.eval(&|word| haystack.contains(&word.to_lowercase()))
146			}) && self
147			.marks
148			.as_ref()
149			.is_none_or(|expr| expr.eval(&|word| case.marks.labels.contains(word)))
150			&& self.only.as_ref().is_none_or(|only| only.contains(&node))
151	}
152}
153
154#[cfg(test)]
155mod tests {
156	use super::*;
157
158	#[test]
159	fn expressions_follow_not_and_or_precedence() {
160		let expr = Expr::parse("a or b and not c").unwrap();
161		let truth = |set: &[&str]| expr.eval(&|word| set.contains(&word));
162		assert!(truth(&["a"]));
163		assert!(truth(&["b"]));
164		assert!(!truth(&["b", "c"]));
165		assert!(
166			!Expr::parse("(a or b) and c")
167				.unwrap()
168				.eval(&|word| word == "a")
169		);
170		for bad in ["", "a and", "(a", "a b", "not"] {
171			assert!(Expr::parse(bad).is_err(), "{bad:?}");
172		}
173	}
174
175	#[test]
176	fn node_patterns_match_whole_segments() {
177		let node: NodeId = "m::events/a.txt::a.1::flag".parse().unwrap();
178		for pattern in [
179			"events/a.txt",
180			"m::events/a.txt::a.1",
181			"events/a.txt::a.1::flag",
182		] {
183			assert!(
184				NodeIdPattern::parse(pattern).unwrap().matches(&node),
185				"{pattern}"
186			);
187		}
188		for pattern in ["events/a", "events/a.txt::a.10", "other::events/a.txt"] {
189			assert!(
190				!NodeIdPattern::parse(pattern).unwrap().matches(&node),
191				"{pattern}"
192			);
193		}
194	}
195}