Skip to main content

foch_test/collect/
mod.rs

1//! Collect cases, fixtures and events from source text.
2//!
3//! Collection never stops at the first problem: every malformed declaration
4//! becomes a diagnostic and valid declarations are still returned, so an
5//! editor can show all problems at once. Whether diagnostics block a run is
6//! the caller's decision. Annotation syntax and value types come from
7//! `foch-annotation`; this module gives them their test meaning.
8
9mod annotation;
10mod tests_dir;
11
12use crate::diagnostic::{Diagnostic, DiagnosticCode};
13use crate::model::{
14	AiMode, EventInfo, Fixture, FixtureName, GameDate, HelperFile, MAX_ADVANCE_DAYS,
15	SessionRequest, Start, Step, Tag, TestCase, valid_case_name, valid_mod_name,
16};
17use crate::source::{SourceFile, SourceKind, SourceSpan};
18use foch::game::eu4::script::parser::{
19	AstStatement, AstValue, ScalarValue, parse_clausewitz_content,
20};
21use foch_annotation::builtin::TEST_PARAMS;
22use foch_annotation::value::{EventId, Value, ValueType, parse_value, scalar};
23use serde::{Deserialize, Serialize};
24use std::collections::HashSet;
25
26/// Upper bound on the cases one `#parametrize`d test may expand to, so a large
27/// Cartesian product cannot exhaust memory during collection. The run-wide
28/// `--max-cases` budget still applies on top of this.
29pub(super) const MAX_PARAMETRIZE_CASES: usize = 256;
30
31#[derive(Clone, Debug, Default, Serialize, Deserialize)]
32pub struct Collection {
33	pub cases: Vec<TestCase>,
34	pub fixtures: Vec<Fixture>,
35	pub events: Vec<EventInfo>,
36	pub helpers: Vec<HelperFile>,
37	pub diagnostics: Vec<Diagnostic>,
38}
39
40impl Collection {
41	pub fn has_errors(&self) -> bool {
42		self.diagnostics
43			.iter()
44			.any(|diagnostic| diagnostic.severity == crate::diagnostic::Severity::Error)
45	}
46}
47
48pub fn collect(files: &[SourceFile]) -> Collection {
49	let mut collection = Collection::default();
50	for file in files {
51		if let Err(message) = valid_mod_name(&file.mod_name) {
52			collection.diagnostics.push(Diagnostic::error(
53				DiagnosticCode::InvalidMod,
54				None,
55				message,
56			));
57			continue;
58		}
59		if !file.path.as_str().ends_with(".txt") {
60			collection.diagnostics.push(Diagnostic::error(
61				DiagnosticCode::Annotation,
62				None,
63				format!("{}: test sources must be EU4 .txt scripts", file.path),
64			));
65			continue;
66		}
67		let parsed = parse_clausewitz_content(crate::game_path(file.path.as_str()), &file.text);
68		if !parsed.diagnostics.is_empty() {
69			for diagnostic in parsed.diagnostics {
70				collection.diagnostics.push(Diagnostic::error(
71					DiagnosticCode::Parse,
72					Some(SourceSpan::from_parser(&file.path, &diagnostic.span)),
73					diagnostic.message,
74				));
75			}
76			continue;
77		}
78		let statements = &parsed.ast.statements;
79		match file.kind {
80			SourceKind::Inline => annotation::collect(file, statements, &mut collection),
81			SourceKind::TestsDir if file.path.is_below("tests/events") => {
82				for statement in statements {
83					if let Some(event) = event_info(file, statement, true) {
84						collection.events.push(event);
85					}
86				}
87				collection.helpers.push(HelperFile {
88					path: file.path.clone(),
89					text: file.text.clone(),
90				});
91			}
92			SourceKind::TestsDir => tests_dir::collect(file, statements, &mut collection),
93		}
94	}
95
96	let mut nodes = HashSet::new();
97	let mut cases = Vec::new();
98	for case in collection.cases.drain(..) {
99		if nodes.insert(case.node.to_string()) {
100			cases.push(case);
101		} else {
102			collection.diagnostics.push(Diagnostic::error(
103				DiagnosticCode::DuplicateCase,
104				Some(case.origin.clone()),
105				format!(
106					"duplicate test {}; give each case a distinct name",
107					case.node
108				),
109			));
110		}
111	}
112	collection.cases = cases;
113
114	let mut names = HashSet::new();
115	let mut fixtures = Vec::new();
116	for fixture in collection.fixtures.drain(..) {
117		if names.insert(fixture.name.clone()) {
118			fixtures.push(fixture);
119		} else {
120			collection.diagnostics.push(Diagnostic::error(
121				DiagnosticCode::DuplicateFixture,
122				Some(fixture.origin.clone()),
123				format!("duplicate fixture {}", fixture.name),
124			));
125		}
126	}
127	collection.fixtures = fixtures;
128	collection
129}
130
131/// Record a top-level `country_event` definition.
132fn event_info(file: &SourceFile, statement: &AstStatement, helper: bool) -> Option<EventInfo> {
133	let AstStatement::Assignment {
134		key,
135		value: AstValue::Block { items, span },
136		..
137	} = statement
138	else {
139		return None;
140	};
141	if key != "country_event" {
142		return None;
143	}
144	let id = single_field(items, "id")
145		.and_then(|value| scalar(value).ok())
146		.and_then(|id| EventId::parse(&id).ok())?;
147	let yes = |name| {
148		matches!(
149			single_field(items, name),
150			Some(AstValue::Scalar {
151				value: ScalarValue::Bool(true),
152				..
153			})
154		)
155	};
156	Some(EventInfo {
157		id,
158		origin: SourceSpan::from_parser(&file.path, span),
159		invocable: yes("hidden") && yes("is_triggered_only"),
160		body: file.text[span.start.offset + 1..span.end.offset - 1].into(),
161		helper,
162	})
163}
164
165fn single_field<'a>(items: &'a [AstStatement], name: &str) -> Option<&'a AstValue> {
166	let mut values = items.iter().filter_map(|item| match item {
167		AstStatement::Assignment { key, value, .. } if key == name => Some(value),
168		_ => None,
169	});
170	let value = values.next()?;
171	values.next().is_none().then_some(value)
172}
173
174/// Parses a `tests/` block value with the type the `#test` schema declares.
175fn typed(file: &SourceFile, key: &str, value: &AstValue) -> Result<Value, String> {
176	let param = TEST_PARAMS
177		.iter()
178		.find(|param| param.name == key)
179		.ok_or_else(|| format!("unknown test key {key}"))?;
180	parse_value(&file.path, &file.text, value, param.value_type)
181		.map_err(|message| format!("{key}: {message}"))
182}
183
184/// Case metadata shared by `#test(...)` annotations and `test = { ... }` blocks.
185#[derive(Default)]
186struct CaseFields {
187	time: Option<GameDate>,
188	tag: Option<Tag>,
189	name: Option<String>,
190	uses: Option<Vec<FixtureName>>,
191	ai: Option<AiMode>,
192	session: Option<SessionRequest>,
193	player: Option<bool>,
194}
195
196const METADATA: &[&str] = &["time", "tag", "name", "use", "ai", "session", "player"];
197
198fn set_once<T>(slot: &mut Option<T>, key: &str, value: T) -> Result<(), String> {
199	if slot.is_some() {
200		return Err(format!("duplicate test parameter {key}"));
201	}
202	*slot = Some(value);
203	Ok(())
204}
205
206impl CaseFields {
207	/// Stores a typed metadata value; returns false for other keys.
208	fn set(&mut self, key: &str, value: &Value) -> Result<bool, String> {
209		match (key, value) {
210			("time", Value::Date(date)) => set_once(&mut self.time, key, *date)?,
211			("tag", Value::Tag(tag)) => set_once(&mut self.tag, key, tag.clone())?,
212			("name", Value::Text(name)) => {
213				valid_case_name(name)?;
214				set_once(&mut self.name, key, name.clone())?;
215			}
216			("use", Value::Identifiers(names)) => set_once(
217				&mut self.uses,
218				key,
219				names
220					.iter()
221					.map(|name| FixtureName::parse(name))
222					.collect::<Result<_, _>>()?,
223			)?,
224			("ai", Value::Choice(mode)) => set_once(
225				&mut self.ai,
226				key,
227				if mode == "on" {
228					AiMode::On
229				} else {
230					AiMode::Off
231				},
232			)?,
233			("session", Value::Choice(request)) => set_once(
234				&mut self.session,
235				key,
236				if request == "alone" {
237					SessionRequest::Alone
238				} else {
239					SessionRequest::Auto
240				},
241			)?,
242			("player", Value::Bool(player)) => set_once(&mut self.player, key, *player)?,
243			_ => return Ok(false),
244		}
245		Ok(true)
246	}
247
248	/// Parses and stores a metadata key of a `tests/` block.
249	fn apply(&mut self, file: &SourceFile, key: &str, value: &AstValue) -> Result<bool, String> {
250		if !METADATA.contains(&key) {
251			return Ok(false);
252		}
253		self.set(key, &typed(file, key, value)?)
254	}
255}
256
257/// Parses a step keyword of a `tests/` block; returns `None` for other keys.
258fn step(file: &SourceFile, key: &str, value: &AstValue) -> Result<Option<Step>, String> {
259	let span = || SourceSpan::from_parser(&file.path, value.span());
260	Ok(Some(match (key, typed_step(file, key, value)?) {
261		(_, None) => return Ok(None),
262		("effect", Some(Value::Effects(block))) => Step::Effect { block },
263		("expect", Some(Value::Conditions(clauses))) => Step::Expect { clauses },
264		("advance_days", Some(Value::Integer(days))) => Step::AdvanceDays { days, span: span() },
265		("fire", Some(Value::EventId(event))) => Step::Fire {
266			event,
267			span: span(),
268		},
269		_ => unreachable!("step types are fixed by the schema"),
270	}))
271}
272
273fn typed_step(file: &SourceFile, key: &str, value: &AstValue) -> Result<Option<Value>, String> {
274	match key {
275		"effect" | "expect" | "advance_days" => typed(file, key, value).map(Some),
276		"fire" => parse_value(&file.path, &file.text, value, ValueType::EventId)
277			.map(Some)
278			.map_err(|message| format!("fire: {message}")),
279		_ => Ok(None),
280	}
281}
282
283/// The start dimensions a case varies over. Empty when nothing is parametrized.
284#[derive(Default)]
285pub(super) struct Axes {
286	pub tags: Option<Vec<Tag>>,
287	pub times: Option<Vec<GameDate>>,
288}
289
290/// One resolved start dimension: its values, and whether it varies (so an
291/// instance records it as a node parameter).
292pub(super) struct Dimension<T> {
293	pub values: Vec<T>,
294	pub varies: bool,
295}
296
297/// Resolves one start dimension from the value given directly on the test and
298/// the list given by `#parametrize`. Giving it in both places is an error, as
299/// is giving it in neither.
300pub(super) fn dimension<T>(
301	direct: Option<T>,
302	axis: Option<Vec<T>>,
303	name: &str,
304) -> Result<Dimension<T>, String> {
305	match (direct, axis) {
306		(Some(_), Some(_)) => Err(format!(
307			"{name} is given by both the test and parametrize; give it in only one"
308		)),
309		(None, Some(values)) => Ok(Dimension {
310			values,
311			varies: true,
312		}),
313		(Some(value), None) => Ok(Dimension {
314			values: vec![value],
315			varies: false,
316		}),
317		(None, None) => Err(format!("missing required test parameter {name}")),
318	}
319}
320
321/// Expands one collected test into its parametrized instances. `base` carries
322/// the shared steps, marks and node; its start is overwritten per instance.
323/// This sets each instance's start and the node parameters that distinguish
324/// it, and recomputes its content identity.
325pub(super) fn expand_cases(
326	base: TestCase,
327	tag: Dimension<Tag>,
328	time: Dimension<GameDate>,
329) -> Result<Vec<TestCase>, String> {
330	let (tags, tag_varies) = (tag.values, tag.varies);
331	let (times, time_varies) = (time.values, time.varies);
332	if let Some(duplicate) = first_duplicate(tags.iter().map(Tag::as_str)) {
333		return Err(format!("parametrize lists tag {duplicate} twice"));
334	}
335	if let Some(duplicate) = first_duplicate(times.iter().map(|time| time.to_string())) {
336		return Err(format!("parametrize lists time {duplicate} twice"));
337	}
338	let product = tags.len() * times.len();
339	if product > MAX_PARAMETRIZE_CASES {
340		return Err(format!(
341			"parametrize expands to {product} cases, more than the limit of {MAX_PARAMETRIZE_CASES}; shorten the lists"
342		));
343	}
344	let mut cases = Vec::with_capacity(product);
345	for date in &times {
346		for tag in &tags {
347			let mut case = base.clone();
348			case.start = Start {
349				date: *date,
350				tag: tag.clone(),
351			};
352			case.node.params = Vec::new();
353			if tag_varies {
354				case.node.params.push(("tag".into(), tag.to_string()));
355			}
356			if time_varies {
357				case.node.params.push(("time".into(), date.to_string()));
358			}
359			case.compute_content_id();
360			cases.push(case);
361		}
362	}
363	Ok(cases)
364}
365
366fn first_duplicate<T: AsRef<str>>(values: impl Iterator<Item = T>) -> Option<String> {
367	let mut seen = HashSet::new();
368	for value in values {
369		if !seen.insert(value.as_ref().to_string()) {
370			return Some(value.as_ref().to_string());
371		}
372	}
373	None
374}
375
376fn total_days(steps: &[Step]) -> Result<(), String> {
377	let total: u64 = steps
378		.iter()
379		.map(|step| match step {
380			Step::AdvanceDays { days, .. } => u64::from(*days),
381			_ => 0,
382		})
383		.sum();
384	if total > u64::from(MAX_ADVANCE_DAYS) {
385		return Err(format!(
386			"a case may advance at most {MAX_ADVANCE_DAYS} days in total"
387		));
388	}
389	Ok(())
390}