use std::collections::HashMap;
use std::ops::Range;
use crate::game::eu4::coercion::{canonical_float_text, canonical_int_text};
use crate::game::eu4::script::parser::{
AstFile, AstStatement, AstValue, ScalarValue, ScriptSyntax, parse_clausewitz_statements,
};
use crate::game::schema::query::{CwtQuery, RuleContext, SchemaScalarType};
use crate::model::GamePath;
pub fn canonicalize_numeric_values_with_active_schema(
relative_path: &GamePath,
file: &AstFile,
) -> AstFile {
canonicalize_numeric_values_at(
relative_path,
file,
Some(crate::game::eu4::cwt::rule_engine()),
)
}
pub(crate) fn canonicalize_numeric_values(file: &AstFile, schema: Option<&CwtQuery>) -> AstFile {
canonicalize_numeric_values_at(&file.path, file, schema)
}
fn canonicalize_numeric_values_at(
relative_path: &GamePath,
file: &AstFile,
schema: Option<&CwtQuery>,
) -> AstFile {
let Some(schema) = schema else {
return file.clone();
};
let mut file = file.clone();
let mut walker = NumericWalker {
schema,
file_path: relative_path,
contexts: HashMap::new(),
edits: Vec::new(),
};
walker.visit(&mut file.statements, &mut Vec::new());
file
}
pub fn canonicalize_numeric_text(relative_path: &GamePath, source: &str) -> String {
canonicalize_numeric_text_with(
relative_path,
source,
Some(crate::game::eu4::cwt::rule_engine()),
)
}
fn canonicalize_numeric_text_with(
relative_path: &GamePath,
source: &str,
schema: Option<&CwtQuery>,
) -> String {
let Some(schema) = schema else {
return source.to_string();
};
let syntax = ScriptSyntax::for_game_path(relative_path);
let parsed = parse_clausewitz_statements(syntax, source);
if !parsed.diagnostics.is_empty() {
return source.to_string();
}
let mut statements = parsed.statements;
let mut walker = NumericWalker {
schema,
file_path: relative_path,
contexts: HashMap::new(),
edits: Vec::new(),
};
walker.visit(&mut statements, &mut Vec::new());
let mut edits = walker.edits;
edits.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
let mut source = source.to_string();
for (range, canonical) in edits {
if source.get(range.clone()).is_some() {
source.replace_range(range, &canonical);
}
}
source
}
struct NumericWalker<'a> {
schema: &'a CwtQuery,
file_path: &'a GamePath,
contexts: HashMap<Vec<String>, Vec<RuleContext<'a>>>,
edits: Vec<(Range<usize>, String)>,
}
impl<'a> NumericWalker<'a> {
fn visit(&mut self, statements: &mut [AstStatement], chain: &mut Vec<String>) {
for statement in statements {
let AstStatement::Assignment { key, value, .. } = statement else {
continue;
};
match value {
AstValue::Block { items, .. } => {
chain.push(key.clone());
self.visit(items, chain);
chain.pop();
}
AstValue::Scalar {
value: ScalarValue::Number(text),
span,
} => {
if let Some(canonical) = self.canonical_text(chain, key, text) {
self.edits
.push((span.start.offset..span.end.offset, canonical.clone()));
*text = canonical;
}
}
AstValue::Scalar { .. } => {}
}
}
}
fn canonical_text(&mut self, chain: &[String], key: &str, text: &str) -> Option<String> {
let scalar_type = self.scalar_type(chain, key)?;
match scalar_type {
SchemaScalarType::Float { .. } => canonical_float_text(text),
SchemaScalarType::Int { .. } => canonical_int_text(text),
SchemaScalarType::Bool => None,
}
.filter(|canonical| canonical != text)
}
fn scalar_type(&mut self, chain: &[String], key: &str) -> Option<SchemaScalarType> {
let contexts = self.contexts_for(chain);
let mut resolved: Option<SchemaScalarType> = None;
for context in contexts {
for field_match in self.schema.bind_field_matches(context, key) {
let Some(scalar_type) = SchemaScalarType::from_rule_value(field_match.value())
else {
return None;
};
match resolved {
None => resolved = Some(scalar_type),
Some(previous) if previous.is_same_primitive(scalar_type) => {}
Some(_) => return None,
}
}
}
resolved
}
fn contexts_for(&mut self, chain: &[String]) -> Vec<RuleContext<'a>> {
if !self.contexts.contains_key(chain) {
let mut readings: Vec<&[String]> = vec![chain];
if let Some((_, rest)) = chain.split_first() {
readings.push(rest);
}
let contexts = readings
.into_iter()
.filter_map(|reading| {
let reading = reading.iter().map(String::as_str).collect::<Vec<_>>();
self.schema.bind_context(self.file_path, &reading)
})
.collect::<Vec<_>>();
self.contexts.insert(chain.to_vec(), contexts);
}
self.contexts[chain].clone()
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::game::eu4::script::emit::emit_clausewitz_statements;
use crate::game::eu4::script::parser::parse_clausewitz_content;
use crate::game::schema::CwtSchema;
const SCHEMA: &str = r#"
types = {
type[thing] = { path = "game/common/things" }
}
thing = {
alias_name[modifier] = alias_match_left[modifier]
untyped = scalar
nested = { depth = float }
}
alias[modifier:upkeep] = float
alias[modifier:slots] = int
alias[modifier:enabled] = bool
"#;
fn schema() -> CwtSchema {
let root = TempDir::new().expect("create schema directory");
fs::write(root.path().join("things.cwt"), SCHEMA).expect("write schema");
CwtSchema::load_with_cache(root.path(), None).expect("load schema")
}
fn canonicalize(path: &str, source: &str, schema: Option<&CwtQuery>) -> String {
let parsed = parse_clausewitz_content(
&crate::model::GamePathBuf::parse(path).expect("valid game path"),
source,
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
let canonical = canonicalize_numeric_values(&parsed.ast, schema);
emit_clausewitz_statements(&canonical.statements).expect("emit")
}
#[test]
fn spellings_of_one_value_become_one_string() {
let schema = schema();
for written in ["0.5", "0.50", "0.500"] {
assert!(
canonicalize(
"common/things/example.txt",
&format!("a_thing = {{ upkeep = {written} }}\n"),
Some(schema.facts()),
)
.contains("upkeep = 0.500"),
"`{written}` should canonicalize"
);
}
}
#[test]
fn a_fourth_decimal_is_truncated_the_way_the_reader_truncates_it() {
let schema = schema();
for written in ["0.1234", "0.1239"] {
assert!(
canonicalize(
"common/things/example.txt",
&format!("a_thing = {{ upkeep = {written} }}\n"),
Some(schema.facts()),
)
.contains("upkeep = 0.123"),
"`{written}` should truncate"
);
}
}
#[test]
fn an_int_field_uses_the_integer_reader() {
let schema = schema();
let output = canonicalize(
"common/things/example.txt",
"a_thing = { slots = 3.4 }\n",
Some(schema.facts()),
);
assert!(output.contains("slots = 3"), "{output}");
assert!(!output.contains("3.4"), "{output}");
}
#[test]
fn a_distinct_value_keeps_its_own_spelling() {
let schema = schema();
let output = canonicalize(
"common/things/example.txt",
"a_thing = { upkeep = 0.6 }\n",
Some(schema.facts()),
);
assert!(output.contains("upkeep = 0.600"), "{output}");
}
#[test]
fn nested_blocks_are_reached() {
let schema = schema();
let output = canonicalize(
"common/things/example.txt",
"a_thing = { nested = { depth = 2.5 } }\n",
Some(schema.facts()),
);
assert!(output.contains("depth = 2.500"), "{output}");
}
#[test]
fn a_field_the_schema_does_not_type_is_left_exactly_as_written() {
let schema = schema();
for source in [
"a_thing = { untyped = 0.50 }\n",
"a_thing = { not_in_the_schema = 0.50 }\n",
"a_thing = { enabled = yes }\n",
] {
assert_eq!(
canonicalize("common/things/example.txt", source, Some(schema.facts())),
canonicalize("common/things/example.txt", source, None),
"{source}"
);
}
}
#[test]
fn a_path_the_schema_does_not_cover_is_left_alone() {
let schema = schema();
let source = "a_thing = { upkeep = 0.50 }\n";
assert!(
canonicalize("common/elsewhere/example.txt", source, Some(schema.facts()))
.contains("upkeep = 0.50"),
);
}
#[test]
fn a_list_item_is_not_a_field_value() {
let schema = schema();
let output = canonicalize(
"common/things/example.txt",
"a_thing = { upkeep = { 0.50 0.1239 } }\n",
Some(schema.facts()),
);
assert!(output.contains("0.50"), "{output}");
assert!(output.contains("0.1239"), "{output}");
}
#[test]
fn no_schema_leaves_every_number_as_written() {
let source = "a_thing = { upkeep = 0.50 slots = 3.4 }\n";
assert!(canonicalize("common/things/example.txt", source, None).contains("upkeep = 0.50"));
}
#[test]
fn rewriting_text_touches_only_the_numbers() {
let source = "# a leading note\n\
a_thing = {\n\
\t# why this value\n\
\tupkeep = 0.5 # trailing note\n\
\tuntyped = 0.50\n\
}\n";
let schema = schema();
let rewritten = canonicalize_numeric_text_with(
GamePath::new("common/things/example.txt").expect("valid game path"),
source,
Some(schema.facts()),
);
assert_eq!(
rewritten,
source.replace("0.5 #", "0.500 #"),
"{rewritten}"
);
}
#[test]
fn rewriting_text_is_a_no_op_where_nothing_is_typed() {
for source in ["a_thing = { untyped = 0.50 }\n", "not_parseable = {\n"] {
assert_eq!(
canonicalize_numeric_text_with(
GamePath::new("common/things/example.txt").expect("valid game path"),
source,
Some(schema().facts())
),
source
);
}
}
#[test]
fn canonicalizing_twice_changes_nothing_the_second_time() {
let schema = schema();
let once = canonicalize(
"common/things/example.txt",
"a_thing = { upkeep = 0.5 slots = 3.4 }\n",
Some(schema.facts()),
);
assert_eq!(
canonicalize("common/things/example.txt", &once, Some(schema.facts())),
once
);
}
}
#[cfg(test)]
mod coverage_probe {
use std::path::{Path, PathBuf};
use super::*;
use crate::game::eu4::script::parser::parse_clausewitz_content;
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "txt") {
out.push(path);
}
}
}
fn redundant(text: &str) -> bool {
match text.split_once('.') {
Some((_, fraction)) => {
!fraction.is_empty()
&& fraction.bytes().all(|b| b.is_ascii_digit())
&& (fraction.ends_with('0') || fraction.len() > 3)
}
None => false,
}
}
fn count(
before: &[AstStatement],
after: &[AstStatement],
redundant_total: &mut usize,
changed: &mut usize,
rewritten: &mut usize,
) {
for (before, after) in before.iter().zip(after) {
let (
AstStatement::Assignment { value: before, .. },
AstStatement::Assignment { value: after, .. },
) = (before, after)
else {
continue;
};
match (before, after) {
(AstValue::Block { items: before, .. }, AstValue::Block { items: after, .. }) => {
count(before, after, redundant_total, changed, rewritten);
}
(
AstValue::Scalar {
value: ScalarValue::Number(before),
..
},
AstValue::Scalar {
value: ScalarValue::Number(after),
..
},
) => {
if redundant(before) {
*redundant_total += 1;
if before != after {
*changed += 1;
}
}
if before != after {
*rewritten += 1;
}
}
_ => {}
}
}
}
#[test]
#[ignore = "P-695 coverage probe: needs EU4_ROOT"]
fn measure_coverage_against_vanilla() {
let schema = crate::game::eu4::cwt::rule_engine();
let root = PathBuf::from(std::env::var("EU4_ROOT").expect("EU4_ROOT"));
let mut files = Vec::new();
for directory in ["common", "events", "decisions", "missions"] {
walk(&root.join(directory), &mut files);
}
files.sort();
let (mut parsed_files, mut redundant_total, mut changed, mut rewritten) =
(0usize, 0usize, 0usize, 0usize);
for file in &files {
let Ok(source) = std::fs::read_to_string(file) else {
continue;
};
let relative = crate::model::GamePathBuf::from_physical(&root, file)
.expect("vanilla file has a game path");
let parsed = parse_clausewitz_content(&relative, &source);
if !parsed.diagnostics.is_empty() {
continue;
}
parsed_files += 1;
let canonical = canonicalize_numeric_values(&parsed.ast, Some(schema));
count(
&parsed.ast.statements,
&canonical.statements,
&mut redundant_total,
&mut changed,
&mut rewritten,
);
}
eprintln!(
"P695 COVERAGE files={parsed_files} redundantly_spelled={redundant_total} \
of_those_covered={changed} numbers_rewritten_total={rewritten}"
);
}
}