pub(crate) mod runtime;
use crate::check::runtime::{build_overlap_findings, build_runtime_state_from_input};
use crate::game::eu4::analysis::rules::{
check_dependency_misuse, check_duplicate_mod_identity, check_duplicate_scripted_effect,
check_file_conflict, check_missing_dependency, check_missing_descriptor, check_required_fields,
check_version_mismatch,
};
use crate::game::eu4::analysis::{AnalyzeOptions, analyze_visibility_with_vanilla_index};
use crate::game::eu4::base::vanilla_index::{VanillaSymbolIndex, VanillaSymbolSource};
use crate::game::eu4::content::eu4;
use crate::input::request::{CheckOptions, InputRequest};
use crate::input::{
InputResolveErrorKind, LoadedModSnapshot, ResolvedInput, ResolvedInputContributor,
resolve_input,
};
use crate::merge::dag::{ModDag, ModId, build_mod_dag};
use crate::merge::namespace::{
KeyContributor, build_family_key_index, detect_key_conflicts, group_by_family,
};
use crate::model::{
AnalysisMeta, AnalysisMode, CheckContext, CheckResult, DocumentFamily, FamilyParseStats,
Finding, FindingChannel, GamePathBuf, ParseFamilyStats, ParseIssueReportItem, SemanticIndex,
Severity, SymbolDefinition, case_only_path_groups,
};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::time::Instant;
pub const CHECK_PROGRESS_TARGET: &str = "crate::check::progress";
fn run_progress_stage<T, F, S>(stage: &'static str, f: F, summarize: S) -> T
where
F: FnOnce() -> T,
S: FnOnce(&T) -> String,
{
tracing::info!(target: CHECK_PROGRESS_TARGET, "check {stage}: start");
let started = Instant::now();
let value = f();
let elapsed_ms = started.elapsed().as_millis() as u64;
crate::merge::timing::record_stage(&format!("check {stage}"), started);
let summary = summarize(&value);
if summary.is_empty() {
tracing::info!(target: CHECK_PROGRESS_TARGET, elapsed_ms, "check {stage}: done");
} else {
tracing::info!(
target: CHECK_PROGRESS_TARGET,
elapsed_ms,
summary = %summary,
"check {stage}: done",
);
}
value
}
#[derive(Clone, Debug)]
struct GameBaseSemanticSnapshot {
index: SemanticIndex,
parsed_files: usize,
parse_error_count: usize,
parse_stats: ParseFamilyStats,
}
impl VanillaSymbolSource for ResolvedInput {
fn visit_vanilla_definitions(&self, visit: &mut dyn FnMut(&SymbolDefinition)) {
let Some(installed) = self.installed_base_snapshot.as_ref() else {
return;
};
let index = installed.snapshot.to_semantic_index();
for definition in &index.definitions {
visit(definition);
}
}
}
pub fn run_checks(request: InputRequest) -> CheckResult {
run_checks_with_options(request, CheckOptions::default())
}
pub fn run_checks_with_options(request: InputRequest, options: CheckOptions) -> CheckResult {
let mut result = CheckResult::default();
let resolved = run_progress_stage(
"resolve input",
|| resolve_input(&request, options.include_game_base),
|res| match res {
Ok(input) => {
let cache_hits = input
.mod_snapshots
.iter()
.flatten()
.filter(|snapshot| snapshot.cache_hit)
.count();
let cache_misses = input
.mod_snapshots
.iter()
.flatten()
.filter(|snapshot| !snapshot.cache_hit)
.count();
format!(
"mods={} inventory_files={} cache_hits={} cache_misses={}",
input.mods.len(),
input.file_inventory.len(),
cache_hits,
cache_misses
)
}
Err(_) => "failed".to_string(),
},
);
let resolved = match resolved {
Ok(input) => input,
Err(err) => {
if err.kind == InputResolveErrorKind::PlaylistFormat {
result.findings.push(Finding {
rule_id: "playset-parse-error".to_string(),
severity: Severity::Error,
channel: FindingChannel::Strict,
message: "failed to parse Playset JSON".to_string(),
mod_id: None,
path: None,
source_file: Some(err.path),
evidence: Some(err.message),
line: None,
column: None,
confidence: Some(1.0),
});
result.recompute_channels();
return result;
}
result.push_fatal_error(err.message);
return result;
}
};
let base_semantic = run_progress_stage(
"base semantic index",
|| {
resolved
.installed_base_snapshot
.as_ref()
.map(|installed| GameBaseSemanticSnapshot {
index: installed.snapshot.to_semantic_index(),
parsed_files: installed.snapshot.parsed_files,
parse_error_count: installed.snapshot.parse_error_count,
parse_stats: installed.snapshot.parse_stats.clone(),
})
},
|_| String::new(),
);
let mod_parsed_files_count: usize = resolved
.mod_snapshots
.iter()
.flatten()
.map(|snapshot| snapshot.parsed_files)
.sum();
let mod_parse_error_count: usize = resolved
.mod_snapshots
.iter()
.flatten()
.map(|snapshot| snapshot.parse_error_count)
.sum();
let mod_parse_stats = resolved
.mod_snapshots
.iter()
.flatten()
.fold(ParseFamilyStats::default(), |acc, snapshot| {
sum_parse_family_stats(acc, snapshot.parse_stats.clone())
});
let mod_semantic_index = run_progress_stage(
"mod semantic indexes",
|| merge_mod_snapshots(&resolved.mod_snapshots),
|_| String::new(),
);
let parsed_files_count = mod_parsed_files_count
+ base_semantic
.as_ref()
.map_or(0, |snapshot| snapshot.parsed_files);
let base_parse_error_count = base_semantic
.as_ref()
.map_or(0, |snapshot| snapshot.parse_error_count);
let total_parse_stats = base_semantic
.as_ref()
.map_or(mod_parse_stats.clone(), |snapshot| {
sum_parse_family_stats(snapshot.parse_stats.clone(), mod_parse_stats.clone())
});
let semantic_index = run_progress_stage(
"merge semantic indexes",
|| match base_semantic {
Some(snapshot) => {
let mut index = snapshot.index;
index.append(mod_semantic_index);
index
}
None => mod_semantic_index,
},
|index| {
format!(
"definitions={} references={} scopes={}",
index.definitions.len(),
index.references.len(),
index.scopes.len()
)
},
);
let game_version = resolved
.installed_base_snapshot
.as_ref()
.map(|installed| installed.snapshot.game_version.clone());
let vanilla_symbol_index = run_progress_stage(
"vanilla symbol index",
|| VanillaSymbolIndex::build(&resolved),
|_| String::new(),
);
let (mod_dag, _dag_diagnostics) = build_mod_dag(&resolved.mods);
let runtime_overlap_findings = if options.analysis_mode == AnalysisMode::Semantic {
let runtime_overlap = run_progress_stage(
"runtime overlap",
|| {
build_runtime_state_from_input(&resolved)
.map(|state| build_overlap_findings(&state))
},
|runtime_overlap| match runtime_overlap {
Ok(findings) => format!("findings={}", findings.len()),
Err(_) => "failed".to_string(),
},
);
match runtime_overlap {
Ok(findings) => findings,
Err(error) => {
result.push_fatal_error(format!("failed to build runtime overlap state: {error}"));
return result;
}
}
} else {
Vec::new()
};
let ctx = CheckContext {
playlist_path: resolved.playlist_path.clone(),
playlist: resolved.playlist,
mods: resolved.mods,
semantic_index,
};
run_progress_stage(
"structural rules",
|| {
result.findings.extend(check_required_fields(&ctx));
result.findings.extend(check_duplicate_mod_identity(&ctx));
result.findings.extend(check_missing_descriptor(&ctx));
result.findings.extend(check_file_conflict(&ctx));
result.findings.extend(check_missing_dependency(&ctx));
if let Some(game_version) = game_version.as_deref() {
result
.findings
.extend(check_version_mismatch(&ctx, game_version));
}
},
|_| String::new(),
);
if options.analysis_mode == AnalysisMode::Semantic {
let overlap_covered_names: HashSet<String> = runtime_overlap_findings
.iter()
.filter(|finding| {
finding.rule_id == "cross-mod-overshadow" || finding.rule_id == "mergeable-overlap"
})
.filter_map(|finding| extract_overlap_symbol_name(&finding.message))
.collect();
let diagnostics = run_progress_stage(
"semantic visibility",
|| {
analyze_visibility_with_vanilla_index(
&ctx.semantic_index,
&AnalyzeOptions {
mode: options.analysis_mode,
},
Some(&vanilla_symbol_index),
)
},
|d| format!("strict={} advisory={}", d.strict.len(), d.advisory.len()),
);
result.findings.extend(
diagnostics
.strict
.into_iter()
.filter(|finding| finding.rule_id != "cross-mod-overshadow"),
);
result.findings.extend(
diagnostics
.advisory
.into_iter()
.filter(|finding| finding.rule_id != "mergeable-overlap"),
);
result.findings.extend(runtime_overlap_findings);
result.findings.extend(run_progress_stage(
"dependency misuse",
|| check_dependency_misuse(&ctx),
|findings| format!("findings={}", findings.len()),
));
let namespace_findings = run_progress_stage(
"namespace conflicts",
|| check_namespace_conflicts(&resolved.file_inventory, &mod_dag),
|findings| format!("findings={}", findings.len()),
);
result
.findings
.extend(namespace_findings.into_iter().filter(|finding| {
!finding
.evidence
.as_deref()
.and_then(extract_namespace_key)
.is_some_and(|key| overlap_covered_names.contains(key))
}));
} else {
result
.findings
.extend(check_duplicate_scripted_effect(&ctx));
}
result
.findings
.extend(check_case_only_path_collisions(&resolved.file_inventory));
result.analysis_meta = AnalysisMeta {
text_documents: ctx.semantic_index.documents.len(),
parsed_files: parsed_files_count,
parse_errors: mod_parse_error_count + base_parse_error_count,
parse_stats: total_parse_stats,
scopes: ctx.semantic_index.scopes.len(),
symbol_definitions: ctx.semantic_index.definitions.len(),
symbol_references: ctx.semantic_index.references.len(),
alias_usages: ctx.semantic_index.alias_usages.len(),
};
result.parse_issue_report = run_progress_stage(
"parse issue report",
|| build_parse_issue_report(&ctx.semantic_index),
|_| String::new(),
);
result.recompute_channels();
crate::platform::memory::release_in_background((
ctx,
resolved.mod_snapshots,
resolved.installed_base_snapshot,
resolved.file_inventory,
resolved.script_cache,
));
result
}
fn merge_mod_snapshots(snapshots: &[Option<LoadedModSnapshot>]) -> SemanticIndex {
let mut merged = SemanticIndex::default();
for snapshot in snapshots.iter().flatten() {
merged.append(snapshot.semantic_index.clone());
}
merged
}
fn sum_parse_family_stats(lhs: ParseFamilyStats, rhs: ParseFamilyStats) -> ParseFamilyStats {
ParseFamilyStats {
clausewitz_mainline: sum_family_parse_stats(
lhs.clausewitz_mainline,
rhs.clausewitz_mainline,
),
localisation: sum_family_parse_stats(lhs.localisation, rhs.localisation),
csv: sum_family_parse_stats(lhs.csv, rhs.csv),
json: sum_family_parse_stats(lhs.json, rhs.json),
}
}
fn sum_family_parse_stats(lhs: FamilyParseStats, rhs: FamilyParseStats) -> FamilyParseStats {
FamilyParseStats {
documents: lhs.documents + rhs.documents,
parse_failed_documents: lhs.parse_failed_documents + rhs.parse_failed_documents,
parse_issue_count: lhs.parse_issue_count + rhs.parse_issue_count,
}
}
fn build_parse_issue_report(index: &SemanticIndex) -> Vec<ParseIssueReportItem> {
let family_lookup = index
.documents
.iter()
.map(|item| {
(
(item.mod_id.as_str(), item.path.as_game_path()),
item.family,
)
})
.collect::<std::collections::HashMap<_, _>>();
let mut items: Vec<ParseIssueReportItem> = index
.parse_issues
.iter()
.map(|issue| ParseIssueReportItem {
family: family_lookup
.get(&(issue.mod_id.as_str(), issue.path.as_game_path()))
.copied()
.unwrap_or(DocumentFamily::Clausewitz),
mod_id: issue.mod_id.clone(),
path: issue.path.clone(),
line: issue.line,
column: issue.column,
message: issue.message.clone(),
})
.collect();
items.sort_by(|lhs, rhs| {
(
format!("{:?}", lhs.family),
lhs.mod_id.as_str(),
&lhs.path,
lhs.line,
lhs.column,
lhs.message.as_str(),
)
.cmp(&(
format!("{:?}", rhs.family),
rhs.mod_id.as_str(),
&rhs.path,
rhs.line,
rhs.column,
rhs.message.as_str(),
))
});
items
}
const NAMESPACE_CHECK_FAMILIES: &[&str] = &["common/scripted_effects", "common/scripted_triggers"];
fn check_case_only_path_collisions(
file_inventory: &BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
) -> Vec<Finding> {
case_only_path_groups(file_inventory.keys().map(GamePathBuf::as_game_path))
.into_iter()
.map(|group| {
let evidence = group
.iter()
.map(|path| {
let mods = file_inventory[*path]
.iter()
.map(|contributor| contributor.mod_id.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("{path} [{mods}]")
})
.collect::<Vec<_>>()
.join("; ");
let primary = group[0];
Finding {
rule_id: "case-only-path-collision".to_string(),
severity: Severity::Warning,
channel: FindingChannel::Advisory,
message: format!(
"{} game paths differ only in letter case; a case-insensitive filesystem keeps only one",
group.len()
),
mod_id: file_inventory[primary]
.last()
.map(|contributor| contributor.mod_id.clone()),
path: Some(primary.to_owned()),
source_file: None,
evidence: Some(evidence),
line: None,
column: None,
confidence: Some(1.0),
}
})
.collect()
}
fn check_namespace_conflicts(
file_inventory: &BTreeMap<GamePathBuf, Vec<ResolvedInputContributor>>,
mod_dag: &ModDag,
) -> Vec<Finding> {
let profile = eu4();
let families_by_id = group_by_family(file_inventory, profile);
let mut findings = Vec::new();
for family_id in NAMESPACE_CHECK_FAMILIES {
let Some(family_files) = families_by_id.get(*family_id) else {
continue;
};
let Some(descriptor) = profile.descriptor_for_root_family(family_id) else {
continue;
};
let Some(merge_key_source) = descriptor.merge_key_source else {
continue;
};
let index = build_family_key_index(family_id, merge_key_source, family_files, profile);
let conflicts = detect_key_conflicts(&index);
for conflict in &conflicts {
let mut seen_mods = HashSet::new();
let non_base: Vec<_> = conflict
.contributors
.iter()
.filter(|c| !c.is_base_game)
.filter(|c| seen_mods.insert(c.mod_id.as_str()))
.collect();
if non_base.len() < 2 {
continue;
}
let leaves = leaf_namespace_contributors(&non_base, mod_dag);
if leaves.len() < 2 {
continue;
}
let primary = leaves[0];
let evidence = format!(
"key={}; mods=[{}]",
conflict.key,
leaves
.iter()
.map(|c| format!("{}:{}", c.mod_id, c.file_path))
.collect::<Vec<_>>()
.join(", ")
);
findings.push(Finding {
rule_id: "cross-file-duplicate-symbol".to_string(),
severity: Severity::Warning,
channel: FindingChannel::Advisory,
message: format!("duplicate key is defined by {} sibling mods", leaves.len()),
mod_id: Some(primary.mod_id.clone()),
path: Some(primary.file_path.clone()),
source_file: None,
evidence: Some(evidence),
line: None,
column: None,
confidence: Some(0.9),
});
}
}
findings
}
fn leaf_namespace_contributors<'a>(
contributors: &[&'a KeyContributor],
mod_dag: &ModDag,
) -> Vec<&'a KeyContributor> {
let ancestor_sets: HashMap<&str, HashSet<ModId>> = contributors
.iter()
.map(|contributor| {
let mod_id = ModId(contributor.mod_id.clone());
(
contributor.mod_id.as_str(),
transitive_mod_ancestors(mod_dag, &mod_id),
)
})
.collect();
contributors
.iter()
.copied()
.filter(|candidate| {
let candidate_id = ModId(candidate.mod_id.clone());
!contributors.iter().any(|other| {
other.mod_id != candidate.mod_id
&& ancestor_sets
.get(other.mod_id.as_str())
.is_some_and(|ancestors| ancestors.contains(&candidate_id))
})
})
.collect()
}
fn transitive_mod_ancestors(mod_dag: &ModDag, mod_id: &ModId) -> HashSet<ModId> {
let mut ancestors = HashSet::new();
let mut pending: Vec<ModId> = mod_dag.parents_of(mod_id).to_vec();
while let Some(parent) = pending.pop() {
if ancestors.insert(parent.clone()) {
pending.extend(mod_dag.parents_of(&parent).iter().cloned());
}
}
ancestors
}
fn extract_overlap_symbol_name(message: &str) -> Option<String> {
let after_colon = message.rsplit_once(':')?.1.trim();
let (_kind, name) = after_colon.split_once(' ')?;
let name = name.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn extract_namespace_key(evidence: &str) -> Option<&str> {
let rest = evidence.strip_prefix("key=")?;
let end = rest.find(';').unwrap_or(rest.len());
Some(rest[..end].trim())
}