Skip to main content

foch_test/
lint.rs

1//! Checks before launching the game, and what each case may touch.
2//!
3//! Facts about the project come from the caller, which derives them from
4//! Foch's analysis. Missing facts skip the corresponding check rather than
5//! guessing. Footprints are deliberately conservative: anything that might
6//! reach another country or global state makes a case unsuitable for sharing.
7
8use crate::diagnostic::{Diagnostic, DiagnosticCode};
9use crate::model::{CaseIndex, EventId, EventInfo, NativeBlock, Step, Tag};
10use crate::plan::Expanded;
11use crate::source::SourceSpan;
12use foch::game::eu4::script::parser::{
13	AstStatement, AstValue, ScalarValue, parse_clausewitz_content,
14};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17
18/// What the caller knows about the project and base game. `None` means
19/// unknown, which disables the related check.
20#[derive(Clone, Debug, Default, Serialize, Deserialize)]
21pub struct ProjectFacts {
22	pub tags: Option<BTreeSet<Tag>>,
23	pub events: Option<BTreeSet<EventId>>,
24	pub effects: Option<BTreeSet<String>>,
25	pub triggers: Option<BTreeSet<String>>,
26}
27
28/// An over-approximation of the state a case reads or writes.
29#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
30pub struct Footprint {
31	pub tags: BTreeSet<Tag>,
32	pub global: bool,
33	/// Why the footprint cannot be bounded, if it cannot.
34	pub opaque: Option<String>,
35}
36
37#[derive(Clone, Debug, Default, Serialize, Deserialize)]
38pub struct LintResult {
39	pub diagnostics: Vec<Diagnostic>,
40	pub footprints: BTreeMap<CaseIndex, Footprint>,
41}
42
43/// Blocks that only structure conditions or effects in the current scope.
44const CONTROL_BLOCKS: &[&str] = &[
45	"if",
46	"else_if",
47	"else",
48	"limit",
49	"AND",
50	"OR",
51	"NOT",
52	"NOR",
53	"NAND",
54	"hidden_effect",
55	"hidden_trigger",
56	"custom_tooltip",
57	"tooltip",
58	"ai_chance",
59	"modifier",
60	"mean_time_to_happen",
61];
62
63/// Event-level blocks executed or evaluated in the event's own scope.
64const EVENT_BLOCKS: &[&str] = &["trigger", "immediate", "option", "after"];
65
66const GLOBAL_KEYS: &[&str] = &["set_global_flag", "clr_global_flag", "has_global_flag"];
67
68pub fn lint(expanded: &Expanded, facts: &ProjectFacts) -> LintResult {
69	let events: BTreeMap<&EventId, &EventInfo> = expanded
70		.events
71		.iter()
72		.map(|event| (&event.id, event))
73		.collect();
74	let mut result = LintResult::default();
75	for planned in &expanded.cases {
76		let case = &planned.case;
77		let mut footprint = Footprint::default();
78		footprint.tags.insert(case.start.tag.clone());
79		if let Some(tags) = &facts.tags
80			&& !tags.contains(&case.start.tag)
81		{
82			result.diagnostics.push(Diagnostic::model_error(
83				DiagnosticCode::UnknownTag,
84				Some(case.origin.clone()),
85				format!(
86					"country {} is not defined by the mod or base game",
87					case.start.tag
88				),
89			));
90		}
91		let mut effects: Vec<&NativeBlock> = Vec::new();
92		let mut conditions = Vec::new();
93		for fixture in &planned.fixtures {
94			effects.extend(&fixture.effect);
95			conditions.extend(&fixture.check);
96		}
97		for step in &case.steps {
98			match step {
99				Step::Effect { block } => effects.push(block),
100				Step::Expect { clauses } => conditions.extend(clauses),
101				Step::Fire { event, span } => {
102					fire_target(
103						event,
104						span,
105						&events,
106						facts,
107						&mut result.diagnostics,
108						&mut footprint,
109					);
110				}
111				Step::AdvanceDays { .. } => {}
112			}
113		}
114		for block in effects {
115			let statements = parse(&block.text);
116			check_keys(
117				&statements,
118				&block.span,
119				facts,
120				Context::Effect,
121				&mut result.diagnostics,
122			);
123			touch(&statements, &mut footprint);
124		}
125		for clause in conditions {
126			let statements = parse(&clause.text);
127			check_keys(
128				&statements,
129				&clause.span,
130				facts,
131				Context::Condition,
132				&mut result.diagnostics,
133			);
134			touch(&statements, &mut footprint);
135		}
136		result.footprints.insert(planned.index, footprint);
137	}
138	result
139}
140
141fn fire_target(
142	event: &EventId,
143	span: &SourceSpan,
144	events: &BTreeMap<&EventId, &EventInfo>,
145	facts: &ProjectFacts,
146	diagnostics: &mut Vec<Diagnostic>,
147	footprint: &mut Footprint,
148) {
149	match events.get(event) {
150		Some(info) if !info.invocable => diagnostics.push(Diagnostic::error(
151			DiagnosticCode::UnsupportedTarget,
152			Some(span.clone()),
153			format!("{event} must declare hidden = yes and is_triggered_only = yes to be fired"),
154		)),
155		Some(info) => {
156			for statement in parse(&info.body) {
157				if let AstStatement::Assignment {
158					key,
159					value: AstValue::Block { items, .. },
160					..
161				} = &statement && EVENT_BLOCKS.contains(&key.as_str())
162				{
163					touch(items, footprint);
164				}
165			}
166		}
167		None => {
168			footprint
169				.opaque
170				.get_or_insert_with(|| format!("fires {event}, whose body was not collected"));
171			let known = facts.events.as_ref().map(|events| events.contains(event));
172			let message = match known {
173				Some(false) => format!("event {event} is not defined"),
174				_ => format!(
175					"cannot verify that {event} is hidden and triggered-only; it was not collected"
176				),
177			};
178			let code = if known == Some(false) {
179				DiagnosticCode::UnknownEvent
180			} else {
181				DiagnosticCode::UnsupportedTarget
182			};
183			diagnostics.push(Diagnostic::model_error(code, Some(span.clone()), message));
184		}
185	}
186}
187
188fn parse(text: &str) -> Vec<AstStatement> {
189	parse_clausewitz_content(crate::game_path("lint.txt"), text)
190		.ast
191		.statements
192}
193
194#[derive(Clone, Copy, PartialEq)]
195enum Context {
196	Effect,
197	Condition,
198}
199
200/// Flags top-level keys used in the wrong kind of block. Keys known as both
201/// or neither (scopes, scripted items) are left alone.
202fn check_keys(
203	statements: &[AstStatement],
204	span: &SourceSpan,
205	facts: &ProjectFacts,
206	context: Context,
207	diagnostics: &mut Vec<Diagnostic>,
208) {
209	let (Some(effects), Some(triggers)) = (&facts.effects, &facts.triggers) else {
210		return;
211	};
212	for statement in statements {
213		let AstStatement::Assignment { key, .. } = statement else {
214			continue;
215		};
216		let (is_effect, is_trigger) = (effects.contains(key), triggers.contains(key));
217		match context {
218			Context::Effect if is_trigger && !is_effect => {
219				diagnostics.push(Diagnostic::model_error(
220					DiagnosticCode::TriggerAsEffect,
221					Some(span.clone()),
222					format!("{key} is a trigger, but this block runs effects"),
223				))
224			}
225			Context::Condition if is_effect && !is_trigger => {
226				diagnostics.push(Diagnostic::model_error(
227					DiagnosticCode::EffectAsTrigger,
228					Some(span.clone()),
229					format!("{key} is an effect, but this block is a condition"),
230				))
231			}
232			_ => {}
233		}
234	}
235}
236
237/// A tag-shaped word that may name a country. `NOT` and `AND` are logic
238/// keywords no country uses; `NOR` is also Norway, so it is kept and the
239/// over-approximation stays safe.
240fn country(word: &str) -> Option<Tag> {
241	if matches!(word, "NOT" | "AND") {
242		return None;
243	}
244	Tag::parse(word).ok()
245}
246
247fn touch(statements: &[AstStatement], footprint: &mut Footprint) {
248	for statement in statements {
249		let (key, value) = match statement {
250			AstStatement::Assignment { key, value, .. } => (Some(key.as_str()), value),
251			AstStatement::Item { value, .. } => (None, value),
252			AstStatement::Comment { .. } => continue,
253		};
254		if let Some(key) = key {
255			if GLOBAL_KEYS.contains(&key) {
256				footprint.global = true;
257			}
258			if let Some(tag) = country(key) {
259				footprint.tags.insert(tag);
260			}
261		}
262		match value {
263			AstValue::Scalar {
264				value: ScalarValue::Identifier(text) | ScalarValue::String(text),
265				..
266			} => {
267				if let Some(tag) = country(text) {
268					footprint.tags.insert(tag);
269				}
270			}
271			AstValue::Scalar { .. } => {}
272			AstValue::Block { items, .. } => {
273				let key = key.unwrap_or_default();
274				if CONTROL_BLOCKS.contains(&key) || country(key).is_some() {
275					touch(items, footprint);
276				} else {
277					footprint.opaque.get_or_insert_with(|| {
278						format!("{key} = {{ ... }} may change scope or reach other countries")
279					});
280				}
281			}
282		}
283	}
284}
285
286#[cfg(test)]
287mod tests {
288	use super::*;
289
290	fn footprint(text: &str) -> Footprint {
291		let mut footprint = Footprint::default();
292		touch(&parse(text), &mut footprint);
293		footprint
294	}
295
296	#[test]
297	fn footprints_are_conservative() {
298		let simple = footprint(
299			"set_country_flag = a if = { limit = { has_country_flag = b } add_prestige = 1 }",
300		);
301		assert!(simple.opaque.is_none() && !simple.global && simple.tags.is_empty());
302		let other = footprint("DAN = { set_country_flag = a } add_truce_with = NOR");
303		assert_eq!(
304			other.tags.iter().map(Tag::as_str).collect::<Vec<_>>(),
305			["DAN", "NOR"]
306		);
307		let logic = footprint("NOT = { has_country_flag = a } AND = { OR = { always = yes } }");
308		assert!(logic.tags.is_empty() && logic.opaque.is_none(), "{logic:?}");
309		assert!(footprint("set_global_flag = x").global);
310		assert!(
311			footprint("every_country = { add_prestige = 1 }")
312				.opaque
313				.is_some()
314		);
315	}
316}