use super::super::error::{MergeError, MergeErrorSubject};
use crate::game::eu4::script::ParsedScriptFile;
use crate::game::eu4::script::parser::{AstStatement, AstValue, ScalarValue, SpanRange};
use std::collections::BTreeSet;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct DefinesAssignmentFragment {
pub merge_key: String,
pub statement_key: String,
pub path_segments: Vec<String>,
pub statement: AstStatement,
pub statement_span: SpanRange,
}
pub(crate) fn normalize_defines_file(
parsed: &ParsedScriptFile,
) -> Result<Vec<DefinesAssignmentFragment>, MergeError> {
let mut fragments = Vec::new();
for statement in &parsed.ast.statements {
collect_defines_fragments(statement, &[], &mut fragments, parsed)?;
}
let mut seen_merge_keys: BTreeSet<&str> = BTreeSet::new();
for fragment in &fragments {
if !seen_merge_keys.insert(&fragment.merge_key) {
return Err(MergeError::Parse {
subject: Some(MergeErrorSubject::Game(parsed.relative_path.to_owned())),
message: format!(
"defines merge cannot safely normalize duplicate leaf path `{}` in {} because Lua uses last-assignment-wins semantics within a file",
fragment.merge_key, parsed.relative_path
),
});
}
}
Ok(fragments)
}
fn collect_defines_fragments(
statement: &AstStatement,
parent_segments: &[String],
fragments: &mut Vec<DefinesAssignmentFragment>,
parsed: &ParsedScriptFile,
) -> Result<(), MergeError> {
match statement {
AstStatement::Comment { .. } => Ok(()),
AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::Identifier(separator),
..
},
..
} if separator == "," => Ok(()),
AstStatement::Item { .. } => Err(MergeError::Parse {
subject: Some(MergeErrorSubject::Game(parsed.relative_path.to_owned())),
message: format!(
"defines merge requires named assignments in {} at {}",
parsed.relative_path,
describe_assignment_path(parent_segments)
),
}),
AstStatement::Assignment {
key, value, span, ..
} => {
let mut path_segments = parent_segments.to_vec();
path_segments.push(key.clone());
match value {
AstValue::Scalar { .. } => {
fragments.push(DefinesAssignmentFragment {
merge_key: path_segments.join("."),
statement_key: key.clone(),
path_segments,
statement: statement.clone(),
statement_span: span.clone(),
});
Ok(())
}
AstValue::Block { items, .. } => {
let fragment_count = fragments.len();
for item in items {
collect_defines_fragments(item, &path_segments, fragments, parsed)?;
}
if fragments.len() == fragment_count {
return Err(MergeError::Parse {
subject: Some(MergeErrorSubject::Game(parsed.relative_path.to_owned())),
message: format!(
"defines merge requires leaf assignments below {} in {}",
describe_assignment_path(&path_segments),
parsed.relative_path
),
});
}
Ok(())
}
}
}
}
}
fn describe_assignment_path(path_segments: &[String]) -> String {
if path_segments.is_empty() {
"<root>".to_string()
} else {
path_segments.join(".")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::eu4::script::ParsedScriptFile;
use crate::game::eu4::script::parser::parse_clausewitz_content;
fn parsed(path: &str, content: &str) -> ParsedScriptFile {
let path_buf = crate::model::GamePathBuf::parse(path).expect("valid game path");
let parse_result = parse_clausewitz_content(&path_buf, content);
ParsedScriptFile {
mod_id: "test_mod".to_string(),
path: None,
relative_path: path_buf,
content_family: None,
file_kind: crate::game::eu4::content::ScriptFileKind::new("other"),
module_name: "test".to_string(),
ast: parse_result.ast,
source: content.to_string(),
parse_issues: Vec::new(),
parse_cache_hit: false,
}
}
#[test]
fn empty_defines_file_is_zero_contribution() {
let file = parsed("common/defines/empty.lua", "");
let fragments = normalize_defines_file(&file).expect("empty file is valid");
assert!(fragments.is_empty());
}
#[test]
fn comment_only_defines_file_is_zero_contribution() {
let file = parsed(
"common/defines/comment_only.lua",
"-- header comment\n--directly second line\n",
);
let fragments = normalize_defines_file(&file).expect("comment-only file is valid");
assert!(fragments.is_empty());
}
#[test]
fn dotted_defines_with_lua_comments_normalize() {
let file = parsed(
"common/defines/idea.lua",
"--直属州维护费\nNDefines.NCountry.STATE_MAINTENANCE_DEV_FACTOR = 0.012\nNDefines.NCountry.PS_BUY_IDEA = 250 -- inline\n",
);
let fragments = normalize_defines_file(&file).expect("file with -- comments is valid");
assert_eq!(fragments.len(), 2);
assert_eq!(
fragments[0].merge_key,
"NDefines.NCountry.STATE_MAINTENANCE_DEV_FACTOR"
);
assert_eq!(fragments[1].merge_key, "NDefines.NCountry.PS_BUY_IDEA");
}
#[test]
fn nested_lua_table_commas_are_separators_not_unnamed_values() {
let file = parsed(
"common/defines.lua",
concat!(
"NDefines = {\n",
"\tNGame = {\n",
"\t\tSTART_DATE = \"1444.11.11\",\n",
"\t\tMAX_CLIENT_STATES = 75,\n",
"\t}\n",
"}\n",
),
);
let fragments = normalize_defines_file(&file).expect("Lua separators are valid");
assert_eq!(fragments.len(), 2);
assert_eq!(fragments[0].merge_key, "NDefines.NGame.START_DATE");
assert_eq!(fragments[1].merge_key, "NDefines.NGame.MAX_CLIENT_STATES");
}
#[test]
fn unnamed_defines_values_other_than_lua_commas_still_fail_closed() {
let file = parsed(
"common/defines.lua",
"NDefines = { NGame = { stray_value } }\n",
);
let err = normalize_defines_file(&file).expect_err("unnamed value must be rejected");
assert!(err.to_string().contains("at NDefines.NGame"), "{err}");
}
#[test]
fn exact_defines_root_rejects_duplicate_full_leaf_path() {
let file = parsed(
"common/defines.lua",
concat!(
"NDefines = {\n",
"\tNGame = {\n",
"\t\tMAX_CLIENT_STATES = 100\n",
"\t\tMAX_CLIENT_STATES = 10\n",
"\t}\n",
"}\n",
),
);
let err = normalize_defines_file(&file)
.expect_err("Lua last-assignment-wins duplicates must fail closed");
let MergeError::Parse { subject, message } = err else {
panic!("expected parse error for duplicate defines leaf");
};
assert_eq!(
subject,
Some(MergeErrorSubject::Game(
crate::model::GamePathBuf::parse("common/defines.lua").expect("valid game path")
))
);
assert!(
message.contains("duplicate leaf path `NDefines.NGame.MAX_CLIENT_STATES`"),
"{message}"
);
assert!(
message.contains("Lua uses last-assignment-wins semantics within a file"),
"{message}"
);
}
#[test]
fn repeated_leaf_name_under_distinct_full_paths_normalizes() {
let file = parsed(
"common/defines.lua",
concat!(
"NDefines.NGame.LIMIT = 1\n",
"NDefines.NCountry.LIMIT = 2\n",
),
);
let fragments = normalize_defines_file(&file).expect("full leaf paths are distinct");
assert_eq!(fragments.len(), 2);
}
#[test]
fn nested_empty_block_still_errors() {
let file = parsed(
"common/defines/nested_empty.lua",
"NDefines.NMilitary = {}\n",
);
let result = normalize_defines_file(&file);
assert!(
result.is_err(),
"nested empty block should still be rejected, got: {result:?}"
);
}
}