1use 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#[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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
30pub struct Footprint {
31 pub tags: BTreeSet<Tag>,
32 pub global: bool,
33 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
43const 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
63const 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
200fn 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
237fn 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}