#![allow(dead_code)]
use std::collections::{BTreeSet, HashMap};
use crate::game::eu4::content::MergeKeySource;
use crate::game::eu4::script::parser::{AstStatement, AstValue};
use crate::game::eu4::script::{ParsedScriptFile, is_decision_container_key};
use super::patch::{ClausewitzPatch, ast_values_semantically_equal, diff_ast, fold_renames};
#[cfg(test)]
use super::patch::{ListItemOccurrence, ListItemTarget};
pub fn apply_patches(
base_statements: &[AstStatement],
patches: &[ClausewitzPatch],
merge_key_source: MergeKeySource,
) -> Vec<AstStatement> {
apply_patches_with_nested(
base_statements,
patches,
merge_key_source,
merge_key_source.nested(),
)
}
pub fn apply_patches_with_nested(
base_statements: &[AstStatement],
patches: &[ClausewitzPatch],
merge_key_source: MergeKeySource,
nested_merge_key_source: MergeKeySource,
) -> Vec<AstStatement> {
apply_at_level(
base_statements,
patches,
&[],
merge_key_source,
nested_merge_key_source,
)
}
pub fn merge_single_mod(
base: &ParsedScriptFile,
overlay: &ParsedScriptFile,
merge_key_source: MergeKeySource,
) -> Vec<AstStatement> {
let patches = diff_ast(base, overlay, merge_key_source);
let patches = fold_renames(patches);
apply_patches(&base.ast.statements, &patches, merge_key_source)
}
fn apply_at_level(
statements: &[AstStatement],
patches: &[ClausewitzPatch],
current_path: &[String],
merge_key_source: MergeKeySource,
nested_merge_key_source: MergeKeySource,
) -> Vec<AstStatement> {
let (local, deeper) = partition_patches(patches, current_path);
let local_by_key = group_by_key(&local);
let depth = current_path.len();
let mut source_positions = HashMap::<String, usize>::new();
let mut value_occurrences = HashMap::<String, Vec<(AstValue, usize)>>::new();
let mut source_totals = HashMap::<String, usize>::new();
for statement in statements {
if let Some(key) = statement_key(statement, merge_key_source, current_path) {
*source_totals.entry(key).or_default() += 1;
}
}
let mut result: Vec<AstStatement> = Vec::with_capacity(statements.len());
let remove_block_items: Vec<&AstValue> = local
.iter()
.filter_map(|p| match p {
ClausewitzPatch::RemoveBlockItem { value, .. } => Some(value),
_ => None,
})
.collect();
for stmt in statements {
if let AstStatement::Item { value, .. } = stmt {
let dropped = remove_block_items
.iter()
.any(|rv| super::patch::values_equal_ignoring_span(rv, value));
if !dropped {
result.push(stmt.clone());
}
continue;
}
let key = statement_key(stmt, merge_key_source, current_path);
let Some(key) = key else {
result.push(stmt.clone());
continue;
};
let source_slot = *source_positions.entry(key.clone()).or_default();
*source_positions.entry(key.clone()).or_default() += 1;
append_list_items_at_source_slot(&mut result, &local, &key, source_slot);
let source_occurrence = stmt_value(stmt)
.map(|value| next_value_occurrence(&mut value_occurrences, &key, value))
.unwrap_or_default();
let key_patches = local_by_key.get(&key);
let removed = key_patches.is_some_and(|patches_for_key| {
should_remove(stmt, patches_for_key, source_slot, source_occurrence)
});
if !removed {
let mut new_stmt = stmt.clone();
if let Some(patches_for_key) = key_patches {
apply_local_patches(&mut new_stmt, patches_for_key);
}
let deeper_for_key = collect_deeper_patches(&deeper, &key, depth);
if !deeper_for_key.is_empty()
&& let AstStatement::Assignment {
value: AstValue::Block { items, span },
..
} = &new_stmt
{
let child_path = {
let mut p = current_path.to_vec();
p.push(key.clone());
p
};
let new_items = apply_at_level(
items,
&deeper_for_key,
&child_path,
nested_merge_key_source,
nested_merge_key_source.nested(),
);
new_stmt = replace_block_items(&new_stmt, new_items, span.clone());
}
result.push(new_stmt);
}
if source_slot + 1 == source_totals[&key] {
append_list_items_at_source_slot(&mut result, &local, &key, source_slot + 1);
}
}
if current_path.is_empty()
&& let MergeKeySource::ContainerChildFieldValue { containers, .. } = merge_key_source
{
for container in containers {
let exists = result
.iter()
.any(|stmt| assignment_key(stmt).is_some_and(|key| key == *container));
if exists {
continue;
}
let container_path = vec![(*container).to_string()];
let deeper_for_container = collect_deeper_patches(&deeper, container, depth);
if deeper_for_container.is_empty() {
continue;
}
let items = apply_at_level(
&[],
&deeper_for_container,
&container_path,
nested_merge_key_source,
nested_merge_key_source.nested(),
);
if !items.is_empty() {
result.push(AstStatement::Assignment {
key: (*container).to_string(),
key_span: dummy_span(),
value: AstValue::Block {
items,
span: dummy_span(),
},
span: dummy_span(),
});
}
}
}
if current_path.is_empty() && merge_key_source == MergeKeySource::ContainerChildKey {
let missing_containers: BTreeSet<String> = deeper
.iter()
.filter_map(|patch| patch_path(patch).first().cloned())
.filter(|key| is_decision_container_key(key))
.collect();
for container in missing_containers {
let exists = result
.iter()
.any(|stmt| assignment_key(stmt).is_some_and(|key| key == container));
if exists {
continue;
}
let container_path = vec![container.clone()];
let deeper_for_container = collect_deeper_patches(&deeper, &container, depth);
if deeper_for_container.is_empty() {
continue;
}
let items = apply_at_level(
&[],
&deeper_for_container,
&container_path,
MergeKeySource::AssignmentKey,
MergeKeySource::AssignmentKey,
);
if !items.is_empty() {
result.push(AstStatement::Assignment {
key: container,
key_span: dummy_span(),
value: AstValue::Block {
items,
span: dummy_span(),
},
span: dummy_span(),
});
}
}
}
for patch in &local {
match patch {
ClausewitzPatch::InsertNode { statement, .. } => {
result.push(statement.clone());
}
ClausewitzPatch::AppendListItem { key, .. }
if source_totals.get(key).copied().unwrap_or_default() == 0 =>
{
append_list_item_statement(&mut result, patch);
}
ClausewitzPatch::AppendListItem { .. } => {}
ClausewitzPatch::AppendBlockItem { value, .. } => {
result.push(AstStatement::Item {
value: value.clone(),
span: dummy_span(),
});
}
_ => {}
}
}
result
}
fn partition_patches(
patches: &[ClausewitzPatch],
current_path: &[String],
) -> (Vec<ClausewitzPatch>, Vec<ClausewitzPatch>) {
let mut local = Vec::new();
let mut deeper = Vec::new();
for patch in patches {
let patch_path = patch_path(patch);
if patch_path == current_path {
local.push(patch.clone());
} else if patch_path.len() > current_path.len()
&& patch_path[..current_path.len()] == *current_path
{
deeper.push(patch.clone());
}
}
(local, deeper)
}
fn collect_deeper_patches(
deeper: &[ClausewitzPatch],
key: &str,
depth: usize,
) -> Vec<ClausewitzPatch> {
deeper
.iter()
.filter(|p| {
let path = patch_path(p);
path.len() > depth && path[depth] == key
})
.cloned()
.collect()
}
fn patch_path(patch: &ClausewitzPatch) -> &[String] {
match patch {
ClausewitzPatch::SetValue { path, .. } => path,
ClausewitzPatch::RemoveNode { path, .. } => path,
ClausewitzPatch::InsertNode { path, .. } => path,
ClausewitzPatch::AppendListItem { path, .. } => path,
ClausewitzPatch::RemoveListItem { path, .. } => path,
ClausewitzPatch::ReplaceBlock { path, .. } => path,
ClausewitzPatch::AppendBlockItem { path, .. } => path,
ClausewitzPatch::RemoveBlockItem { path, .. } => path,
ClausewitzPatch::Rename { path, .. } => path,
}
}
fn patch_key(patch: &ClausewitzPatch) -> Option<&str> {
match patch {
ClausewitzPatch::SetValue { key, .. } => Some(key),
ClausewitzPatch::RemoveNode { key, .. } => Some(key),
ClausewitzPatch::InsertNode { key, .. } => Some(key),
ClausewitzPatch::AppendListItem { key, .. } => Some(key),
ClausewitzPatch::RemoveListItem { key, .. } => Some(key),
ClausewitzPatch::ReplaceBlock { key, .. } => Some(key),
ClausewitzPatch::AppendBlockItem { .. } | ClausewitzPatch::RemoveBlockItem { .. } => None,
ClausewitzPatch::Rename { old_key, .. } => Some(old_key),
}
}
fn statement_key(
stmt: &AstStatement,
merge_key_source: MergeKeySource,
current_path: &[String],
) -> Option<String> {
if let MergeKeySource::ContainerChildFieldValue {
containers,
child_key_field,
child_types,
} = merge_key_source
&& current_path.len() == 1
&& containers.contains(¤t_path[0].as_str())
{
return container_child_field_value_key(stmt, child_key_field, child_types);
}
if let MergeKeySource::ChildFieldValue {
child_key_field,
child_types,
} = merge_key_source
{
return child_field_value_key(stmt, child_key_field, child_types);
}
if !current_path.is_empty() {
return assignment_key(stmt);
}
match merge_key_source {
MergeKeySource::AssignmentKey
| MergeKeySource::LeafPath
| MergeKeySource::ContainerChildKey
| MergeKeySource::ContainerChildFieldValue { .. } => assignment_key(stmt),
MergeKeySource::ChildFieldValue { .. } => unreachable!("handled before nested fallback"),
MergeKeySource::FieldValue(field) => field_value_key(stmt, field),
}
}
fn assignment_key(stmt: &AstStatement) -> Option<String> {
match stmt {
AstStatement::Assignment { key, .. } => Some(key.clone()),
_ => None,
}
}
fn field_value_key(stmt: &AstStatement, field: &str) -> Option<String> {
if let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = stmt
{
return scalar_assignment_value(items, field);
}
None
}
fn container_child_field_value_key(
stmt: &AstStatement,
child_key_field: &str,
child_types: &[&str],
) -> Option<String> {
let AstStatement::Assignment { key, value, .. } = stmt else {
return None;
};
if (child_types.is_empty() || child_types.contains(&key.as_str()))
&& let AstValue::Block { items, .. } = value
&& let Some(field_value) = scalar_assignment_value(items, child_key_field)
{
return Some(format!("{key}:{field_value}"));
}
Some(key.clone())
}
fn child_field_value_key(
stmt: &AstStatement,
child_key_field: &str,
child_types: &[&str],
) -> Option<String> {
let AstStatement::Assignment { key, value, .. } = stmt else {
return None;
};
if let AstValue::Block { items, .. } = value
&& let Some(field_value) = scalar_assignment_value(items, key)
{
return Some(format!("{key}:{field_value}"));
}
if (child_types.is_empty() || child_types.contains(&key.as_str()))
&& let AstValue::Block { items, .. } = value
&& let Some(field_value) = descendant_scalar_assignment_value(items, child_key_field)
{
return Some(format!("{key}:{field_value}"));
}
Some(key.clone())
}
fn scalar_assignment_value(items: &[AstStatement], expected_key: &str) -> Option<String> {
for item in items {
if let AstStatement::Assignment {
key,
value: AstValue::Scalar { value, .. },
..
} = item && key == expected_key
{
return Some(value.as_text());
}
}
None
}
fn descendant_scalar_assignment_value(
items: &[AstStatement],
expected_key: &str,
) -> Option<String> {
for item in items {
let AstStatement::Assignment { key, value, .. } = item else {
continue;
};
if key == expected_key
&& let AstValue::Scalar { value, .. } = value
{
return Some(value.as_text());
}
if let AstValue::Block { items, .. } = value
&& let Some(found) = descendant_scalar_assignment_value(items, expected_key)
{
return Some(found);
}
}
None
}
fn group_by_key(patches: &[ClausewitzPatch]) -> HashMap<String, Vec<&ClausewitzPatch>> {
let mut map: HashMap<String, Vec<&ClausewitzPatch>> = HashMap::new();
for patch in patches {
if let Some(key) = patch_key(patch) {
map.entry(key.to_string()).or_default().push(patch);
}
}
map
}
fn should_remove(
stmt: &AstStatement,
patches: &[&ClausewitzPatch],
source_slot: usize,
value_occurrence: usize,
) -> bool {
if patches
.iter()
.any(|patch| matches!(patch, ClausewitzPatch::RemoveNode { .. }))
{
return true;
}
let Some(value) = stmt_value(stmt) else {
return false;
};
patches.iter().any(|patch| {
matches!(
patch,
ClausewitzPatch::RemoveListItem {
value: removed,
source_occurrence,
..
} if source_occurrence.matches_source(value_occurrence, source_slot)
&& ast_values_semantically_equal(removed, value)
)
})
}
fn next_value_occurrence(
occurrences: &mut HashMap<String, Vec<(AstValue, usize)>>,
key: &str,
value: &AstValue,
) -> usize {
let values = occurrences.entry(key.to_string()).or_default();
if let Some((_, next)) = values
.iter_mut()
.find(|(known, _)| ast_values_semantically_equal(known, value))
{
let occurrence = *next;
*next += 1;
return occurrence;
}
values.push((value.clone(), 1));
0
}
fn append_list_items_at_source_slot(
statements: &mut Vec<AstStatement>,
patches: &[ClausewitzPatch],
key: &str,
source_slot: usize,
) {
let mut matching = patches
.iter()
.filter_map(|patch| match patch {
ClausewitzPatch::AppendListItem {
key: patch_key,
target_occurrence,
..
} if patch_key == key => {
let insertion = target_occurrence.insertion();
(insertion.source_slot == source_slot).then_some((insertion, patch))
}
_ => None,
})
.collect::<Vec<_>>();
matching.sort_by_key(|(insertion, _)| insertion.target_ordinal);
for (_, patch) in matching {
append_list_item_statement(statements, patch);
}
}
fn append_list_item_statement(statements: &mut Vec<AstStatement>, patch: &ClausewitzPatch) {
let ClausewitzPatch::AppendListItem { key, value, .. } = patch else {
return;
};
statements.push(AstStatement::Assignment {
key: key.clone(),
key_span: dummy_span(),
value: value.clone(),
span: dummy_span(),
});
}
fn apply_local_patches(stmt: &mut AstStatement, patches: &[&ClausewitzPatch]) {
for patch in patches {
match patch {
ClausewitzPatch::SetValue { new_value, .. } => {
set_stmt_value(stmt, new_value.clone());
}
ClausewitzPatch::ReplaceBlock { new_statement, .. } => {
*stmt = new_statement.clone();
}
ClausewitzPatch::Rename { new_key, .. } => {
if let AstStatement::Assignment { key, .. } = stmt {
*key = new_key.clone();
}
}
_ => {}
}
}
}
fn stmt_value(stmt: &AstStatement) -> Option<&AstValue> {
match stmt {
AstStatement::Assignment { value, .. } => Some(value),
AstStatement::Item { value, .. } => Some(value),
AstStatement::Comment { .. } => None,
}
}
fn set_stmt_value(stmt: &mut AstStatement, new_value: AstValue) {
match stmt {
AstStatement::Assignment { value, .. } => *value = new_value,
AstStatement::Item { value, .. } => *value = new_value,
AstStatement::Comment { .. } => {}
}
}
fn replace_block_items(
stmt: &AstStatement,
new_items: Vec<AstStatement>,
block_span: crate::game::eu4::script::parser::SpanRange,
) -> AstStatement {
match stmt {
AstStatement::Assignment {
key,
key_span,
span,
..
} => AstStatement::Assignment {
key: key.clone(),
key_span: key_span.clone(),
value: AstValue::Block {
items: new_items,
span: block_span,
},
span: span.clone(),
},
other => other.clone(),
}
}
fn dummy_span() -> crate::game::eu4::script::parser::SpanRange {
use crate::game::eu4::script::parser::{Span, SpanRange};
SpanRange {
start: Span {
line: 0,
column: 0,
offset: 0,
},
end: Span {
line: 0,
column: 0,
offset: 0,
},
}
}
#[cfg(test)]
mod tests {
use super::super::patch::ast_statements_semantically_equal;
use super::*;
use crate::game::eu4::content::ScriptFileKind;
use crate::game::eu4::script::parser::{AstFile, ScalarValue, Span, SpanRange};
fn test_span() -> SpanRange {
SpanRange {
start: Span {
line: 1,
column: 1,
offset: 0,
},
end: Span {
line: 1,
column: 1,
offset: 0,
},
}
}
fn scalar(value: &str) -> AstValue {
AstValue::Scalar {
value: ScalarValue::Identifier(value.to_string()),
span: test_span(),
}
}
fn string_scalar(value: &str) -> AstValue {
AstValue::Scalar {
value: ScalarValue::String(value.to_string()),
span: test_span(),
}
}
fn assignment(key: &str, value: AstValue) -> AstStatement {
AstStatement::Assignment {
key: key.to_string(),
key_span: test_span(),
value,
span: test_span(),
}
}
fn block(key: &str, items: Vec<AstStatement>) -> AstStatement {
AstStatement::Assignment {
key: key.to_string(),
key_span: test_span(),
value: AstValue::Block {
items,
span: test_span(),
},
span: test_span(),
}
}
fn make_parsed(statements: Vec<AstStatement>) -> ParsedScriptFile {
ParsedScriptFile {
mod_id: "test".to_string(),
path: None,
relative_path: crate::model::GamePathBuf::parse("test.txt").expect("valid game path"),
content_family: None,
file_kind: ScriptFileKind::new("other"),
module_name: "test".to_string(),
source: String::new(),
ast: AstFile {
path: crate::model::GamePathBuf::parse("test.txt").expect("valid game path"),
statements,
},
parse_issues: Vec::new(),
parse_cache_hit: false,
}
}
#[test]
fn empty_patches_leave_base_unchanged() {
let base = vec![
assignment("tax", scalar("5")),
assignment("manpower", scalar("1000")),
];
let result = apply_patches(&base, &[], MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 2);
assert_eq!(result, base);
}
#[test]
fn insert_node_appends_statement() {
let base = vec![assignment("tax", scalar("5"))];
let patches = vec![ClausewitzPatch::InsertNode {
path: vec![],
key: "manpower".to_string(),
statement: assignment("manpower", scalar("1000")),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 2);
assert!(matches!(&result[1], AstStatement::Assignment { key, .. } if key == "manpower"));
}
#[test]
fn remove_node_drops_statement() {
let base = vec![
assignment("tax", scalar("5")),
assignment("manpower", scalar("1000")),
];
let patches = vec![ClausewitzPatch::RemoveNode {
path: vec![],
key: "manpower".to_string(),
removed: assignment("manpower", scalar("1000")),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 1);
assert!(matches!(&result[0], AstStatement::Assignment { key, .. } if key == "tax"));
}
#[test]
fn set_value_changes_scalar() {
let base = vec![assignment("tax", scalar("5"))];
let patches = vec![ClausewitzPatch::SetValue {
path: vec![],
key: "tax".to_string(),
old_value: scalar("5"),
new_value: scalar("10"),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 1);
assert!(matches!(
&result[0],
AstStatement::Assignment {
key,
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if key == "tax" && v == "10"
));
}
#[test]
fn append_list_item_adds_to_block() {
let base = vec![
assignment("tag", scalar("FRA")),
assignment("tag", scalar("SPA")),
];
let patches = vec![ClausewitzPatch::AppendListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("ENG"),
target_occurrence: ListItemTarget::new(0, 2, 2),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 3);
assert!(matches!(
&result[2],
AstStatement::Assignment {
key,
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if key == "tag" && v == "ENG"
));
}
#[test]
fn remove_list_item_drops_from_repeated() {
let base = vec![
assignment("tag", scalar("FRA")),
assignment("tag", scalar("ENG")),
assignment("tag", scalar("SPA")),
];
let patches = vec![ClausewitzPatch::RemoveListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("ENG"),
source_occurrence: ListItemOccurrence::source(0, 1),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 2);
assert!(matches!(
&result[0],
AstStatement::Assignment {
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if v == "FRA"
));
assert!(matches!(
&result[1],
AstStatement::Assignment {
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if v == "SPA"
));
}
#[test]
fn remove_list_item_targets_exact_source_occurrences() {
let base = vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("B")),
assignment("tag", scalar("A")),
];
let remove_last_a = ClausewitzPatch::RemoveListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("A"),
source_occurrence: ListItemOccurrence::source(1, 2),
};
let last_removed = apply_patches(
&base,
std::slice::from_ref(&remove_last_a),
MergeKeySource::AssignmentKey,
);
assert_eq!(
last_removed,
vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("B"))
]
);
let first_removed = apply_patches(
&base,
&[ClausewitzPatch::RemoveListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("A"),
source_occurrence: ListItemOccurrence::source(0, 0),
}],
MergeKeySource::AssignmentKey,
);
assert_eq!(
first_removed,
vec![
assignment("tag", scalar("B")),
assignment("tag", scalar("A"))
]
);
let three_as = vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("A")),
assignment("tag", scalar("A")),
assignment("tag", scalar("B")),
];
let two_removed = apply_patches(
&three_as,
&[
ClausewitzPatch::RemoveListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("A"),
source_occurrence: ListItemOccurrence::source(0, 0),
},
ClausewitzPatch::RemoveListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("A"),
source_occurrence: ListItemOccurrence::source(2, 2),
},
],
MergeKeySource::AssignmentKey,
);
assert_eq!(
two_removed,
vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("B"))
]
);
}
#[test]
fn append_list_item_inserts_at_target_occurrence() {
let base = vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("C")),
];
let result = apply_patches(
&base,
&[ClausewitzPatch::AppendListItem {
path: vec![],
key: "tag".to_string(),
value: scalar("B"),
target_occurrence: ListItemTarget::new(0, 1, 1),
}],
MergeKeySource::AssignmentKey,
);
let expected = vec![
assignment("tag", scalar("A")),
assignment("tag", scalar("B")),
assignment("tag", scalar("C")),
];
assert_eq!(result.len(), expected.len());
assert!(
result
.iter()
.zip(&expected)
.all(|(actual, expected)| ast_statements_semantically_equal(actual, expected))
);
}
#[test]
fn nested_set_value_modifies_inner_block() {
let base = vec![block(
"my_trigger",
vec![
block("OR", vec![assignment("tag", scalar("FRA"))]),
assignment("is_subject", scalar("no")),
],
)];
let patches = vec![ClausewitzPatch::SetValue {
path: vec!["my_trigger".to_string()],
key: "is_subject".to_string(),
old_value: scalar("no"),
new_value: scalar("yes"),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 1);
if let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = &result[0]
{
assert_eq!(items.len(), 2);
assert!(matches!(
&items[1],
AstStatement::Assignment {
key,
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if key == "is_subject" && v == "yes"
));
} else {
panic!("expected block assignment");
}
}
#[test]
fn replace_block_swaps_statement() {
let old_stmt = block("my_effect", vec![assignment("add_stability", scalar("1"))]);
let new_stmt = block(
"my_effect",
vec![
assignment("add_stability", scalar("2")),
assignment("add_prestige", scalar("10")),
],
);
let base = vec![old_stmt.clone()];
let patches = vec![ClausewitzPatch::ReplaceBlock {
path: vec![],
key: "my_effect".to_string(),
old_statement: old_stmt,
new_statement: new_stmt.clone(),
}];
let result = apply_patches(&base, &patches, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 1);
assert_eq!(result[0], new_stmt);
}
fn ast_eq_modulo_span(a: &AstStatement, b: &AstStatement) -> bool {
match (a, b) {
(
AstStatement::Assignment {
key: ka, value: va, ..
},
AstStatement::Assignment {
key: kb, value: vb, ..
},
) => ka == kb && value_eq_modulo_span(va, vb),
(AstStatement::Item { value: va, .. }, AstStatement::Item { value: vb, .. }) => {
value_eq_modulo_span(va, vb)
}
(AstStatement::Comment { text: ta, .. }, AstStatement::Comment { text: tb, .. }) => {
ta == tb
}
_ => false,
}
}
fn value_eq_modulo_span(a: &AstValue, b: &AstValue) -> bool {
match (a, b) {
(AstValue::Scalar { value: va, .. }, AstValue::Scalar { value: vb, .. }) => va == vb,
(AstValue::Block { items: ia, .. }, AstValue::Block { items: ib, .. }) => {
ia.len() == ib.len()
&& ia
.iter()
.zip(ib.iter())
.all(|(x, y)| ast_eq_modulo_span(x, y))
}
_ => false,
}
}
#[test]
fn field_value_diff_apply_round_trip_event_id() {
let make_event = |id: &str, title: &str| {
block(
"country_event",
vec![
assignment("id", scalar(id)),
assignment("title", scalar(title)),
],
)
};
let base_stmts = vec![
make_event("test.1", "old_title_1"),
make_event("test.2", "title_2"),
];
let overlay_stmts = vec![
make_event("test.1", "new_title_1"),
make_event("test.2", "title_2"),
];
let key_source = MergeKeySource::FieldValue("id");
let noop = merge_single_mod(
&make_parsed(base_stmts.clone()),
&make_parsed(base_stmts.clone()),
key_source,
);
assert_eq!(noop.len(), base_stmts.len());
for (a, b) in noop.iter().zip(base_stmts.iter()) {
assert!(ast_eq_modulo_span(a, b));
}
let result = merge_single_mod(
&make_parsed(base_stmts.clone()),
&make_parsed(overlay_stmts.clone()),
key_source,
);
assert_eq!(result.len(), overlay_stmts.len());
for (a, b) in result.iter().zip(overlay_stmts.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"FieldValue round-trip mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn container_child_key_diff_apply_round_trip_decision() {
let make_decisions = |children: Vec<AstStatement>| block("country_decisions", children);
let base = vec![make_decisions(vec![
block(
"pragmatic_sanction",
vec![assignment("potential", scalar("yes"))],
),
block(
"other_decision",
vec![assignment("potential", scalar("no"))],
),
])];
let overlay = vec![make_decisions(vec![
block(
"pragmatic_sanction",
vec![assignment("potential", scalar("always"))],
),
block(
"other_decision",
vec![assignment("potential", scalar("no"))],
),
block("new_decision", vec![assignment("potential", scalar("yes"))]),
])];
let key_source = MergeKeySource::ContainerChildKey;
let noop = merge_single_mod(
&make_parsed(base.clone()),
&make_parsed(base.clone()),
key_source,
);
assert_eq!(noop.len(), base.len());
for (a, b) in noop.iter().zip(base.iter()) {
assert!(ast_eq_modulo_span(a, b));
}
let result = merge_single_mod(
&make_parsed(base.clone()),
&make_parsed(overlay.clone()),
key_source,
);
assert_eq!(result.len(), overlay.len());
for (a, b) in result.iter().zip(overlay.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"ContainerChildKey round-trip mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn container_child_key_diff_apply_recreates_missing_decision_container() {
let decision = block(
"pragmatic_sanction_decision",
vec![assignment("potential", scalar("yes"))],
);
let overlay = vec![block("country_decisions", vec![decision])];
let result = merge_single_mod(
&make_parsed(Vec::new()),
&make_parsed(overlay.clone()),
MergeKeySource::ContainerChildKey,
);
assert_eq!(result.len(), overlay.len());
for (a, b) in result.iter().zip(overlay.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"ContainerChildKey empty-base apply mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn container_child_field_value_diff_apply_round_trip_gui_types() {
const GUI_CHILD_TYPES: &[&str] = &["windowType", "containerWindowType"];
let make_gui = |children: Vec<AstStatement>| block("guiTypes", children);
let make_window = |name: &str, x: &str, y: &str| {
block(
"windowType",
vec![
assignment("name", string_scalar(name)),
block(
"position",
vec![assignment("x", scalar(x)), assignment("y", scalar(y))],
),
],
)
};
let base = vec![make_gui(vec![
make_window("country_court_view", "0", "0"),
make_window("topbar_panel", "10", "0"),
])];
let overlay = vec![make_gui(vec![
make_window("country_court_view", "1", "0"),
make_window("topbar_panel", "10", "2"),
])];
let key_source = MergeKeySource::ContainerChildFieldValue {
containers: &["guiTypes"],
child_key_field: "name",
child_types: GUI_CHILD_TYPES,
};
let result = merge_single_mod(
&make_parsed(base),
&make_parsed(overlay.clone()),
key_source,
);
assert_eq!(result.len(), overlay.len());
for (a, b) in result.iter().zip(overlay.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"ContainerChildFieldValue round-trip mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn container_child_field_value_diff_apply_round_trip_sprite_types() {
const GFX_CHILD_TYPES: &[&str] = &["spriteType"];
let make_sprites = |children: Vec<AstStatement>| block("spriteTypes", children);
let make_sprite = |name: &str, texture: &str| {
block(
"spriteType",
vec![
assignment("name", string_scalar(name)),
assignment("texturefile", string_scalar(texture)),
],
)
};
let base = vec![make_sprites(vec![])];
let overlay = vec![make_sprites(vec![
make_sprite("GFX_test_sprite_a", "gfx/test_sprite_a.dds"),
make_sprite("GFX_test_sprite_b", "gfx/test_sprite_b.dds"),
])];
let key_source = MergeKeySource::ContainerChildFieldValue {
containers: &["guiTypes", "spriteTypes"],
child_key_field: "name",
child_types: GFX_CHILD_TYPES,
};
let result = merge_single_mod(
&make_parsed(base),
&make_parsed(overlay.clone()),
key_source,
);
assert_eq!(result.len(), overlay.len());
for (a, b) in result.iter().zip(overlay.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"ContainerChildFieldValue sprite round-trip mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn leaf_path_diff_apply_round_trip_dotted_assignment() {
let base = vec![
assignment("NGame.START_YEAR", scalar("1444")),
assignment("NGame.END_YEAR", scalar("1821")),
assignment("NCountry.BASE_TAX", scalar("1")),
];
let overlay = vec![
assignment("NGame.START_YEAR", scalar("1356")),
assignment("NGame.END_YEAR", scalar("1821")),
assignment("NCountry.BASE_TAX", scalar("2")),
];
let key_source = MergeKeySource::LeafPath;
let noop = merge_single_mod(
&make_parsed(base.clone()),
&make_parsed(base.clone()),
key_source,
);
assert_eq!(noop.len(), base.len());
for (a, b) in noop.iter().zip(base.iter()) {
assert!(ast_eq_modulo_span(a, b));
}
let result = merge_single_mod(
&make_parsed(base.clone()),
&make_parsed(overlay.clone()),
key_source,
);
assert_eq!(result.len(), overlay.len());
for (a, b) in result.iter().zip(overlay.iter()) {
assert!(
ast_eq_modulo_span(a, b),
"LeafPath round-trip mismatch: {a:?} vs {b:?}"
);
}
}
#[test]
fn merge_single_mod_roundtrip() {
let base_stmts = vec![
assignment("tax", scalar("5")),
block("trigger", vec![assignment("tag", scalar("FRA"))]),
];
let overlay_stmts = vec![
assignment("tax", scalar("10")),
block("trigger", vec![assignment("tag", scalar("ENG"))]),
];
let base = make_parsed(base_stmts);
let overlay = make_parsed(overlay_stmts.clone());
let result = merge_single_mod(&base, &overlay, MergeKeySource::AssignmentKey);
assert_eq!(result.len(), 2);
assert!(matches!(
&result[0],
AstStatement::Assignment {
key,
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if key == "tax" && v == "10"
));
if let AstStatement::Assignment {
key,
value: AstValue::Block { items, .. },
..
} = &result[1]
{
assert_eq!(key, "trigger");
assert!(matches!(
&items[0],
AstStatement::Assignment {
key: k,
value: AstValue::Scalar {
value: ScalarValue::Identifier(v),
..
},
..
} if k == "tag" && v == "ENG"
));
} else {
panic!("expected block trigger");
}
}
fn item(value: AstValue) -> AstStatement {
AstStatement::Item {
value,
span: test_span(),
}
}
#[test]
fn round_trip_block_item_addition() {
let base = make_parsed(vec![block(
"allowed_tags",
vec![item(scalar("FRA")), item(scalar("ENG"))],
)]);
let overlay = make_parsed(vec![block(
"allowed_tags",
vec![
item(scalar("FRA")),
item(scalar("ENG")),
item(scalar("TRE")),
],
)]);
let result = merge_single_mod(&base, &overlay, MergeKeySource::AssignmentKey);
let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = &result[0]
else {
panic!("expected block, got {:?}", result[0]);
};
let names: Vec<String> = items
.iter()
.filter_map(|s| match s {
AstStatement::Item {
value:
AstValue::Scalar {
value: ScalarValue::Identifier(n),
..
},
..
} => Some(n.clone()),
_ => None,
})
.collect();
assert_eq!(names, vec!["FRA", "ENG", "TRE"]);
}
#[test]
fn round_trip_block_item_removal() {
let base = make_parsed(vec![block(
"allowed_tags",
vec![item(scalar("FRA")), item(scalar("ENG"))],
)]);
let overlay = make_parsed(vec![block("allowed_tags", vec![item(scalar("FRA"))])]);
let result = merge_single_mod(&base, &overlay, MergeKeySource::AssignmentKey);
let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = &result[0]
else {
panic!("expected block");
};
let names: Vec<String> = items
.iter()
.filter_map(|s| match s {
AstStatement::Item {
value:
AstValue::Scalar {
value: ScalarValue::Identifier(n),
..
},
..
} => Some(n.clone()),
_ => None,
})
.collect();
assert_eq!(names, vec!["FRA"]);
}
}