use crate::game::eu4::script::parser::{AstStatement, AstValue, Span, SpanRange};
use super::patch::{ScalarEquality, ast_statements_equal_ignoring_comments};
pub(crate) fn canonical_boolean_or_body(body: Vec<AstStatement>) -> Vec<AstStatement> {
let mut comments = Vec::new();
let disjuncts = unique_disjuncts(body_to_disjuncts(body, &mut comments), &mut comments);
boolean_or_body(comments, disjuncts)
}
pub(crate) fn combine_boolean_or_bodies(
bodies: impl IntoIterator<Item = Vec<AstStatement>>,
) -> Option<Vec<AstStatement>> {
let mut comments = Vec::new();
let collected = bodies
.into_iter()
.flat_map(|body| body_to_disjuncts(body, &mut comments))
.collect();
let disjuncts = unique_disjuncts(collected, &mut comments);
(!disjuncts.is_empty()).then(|| boolean_or_body(comments, disjuncts))
}
pub(crate) fn simplify_boolean_or_body(body: Vec<AstStatement>) -> Vec<AstStatement> {
let mut comments = Vec::new();
let mut disjuncts = unique_disjuncts(body_to_disjuncts(body, &mut comments), &mut comments);
if disjuncts.len() != 1 {
return boolean_or_body(comments, disjuncts);
}
let disjunct = disjuncts.pop().expect("single disjunct checked");
let mut body = comments;
match disjunct {
AstStatement::Assignment {
key,
value: AstValue::Block { items, .. },
..
} if key == "AND" => body.extend(items),
other => body.push(other),
}
body
}
fn boolean_or_body(
mut comments: Vec<AstStatement>,
disjuncts: Vec<AstStatement>,
) -> Vec<AstStatement> {
if !disjuncts.is_empty() {
comments.push(make_boolean_block("OR", disjuncts));
}
comments
}
fn unique_disjuncts(
disjuncts: Vec<AstStatement>,
comments: &mut Vec<AstStatement>,
) -> Vec<AstStatement> {
let mut unique: Vec<AstStatement> = Vec::with_capacity(disjuncts.len());
for disjunct in disjuncts {
if unique.iter().any(|existing| {
ast_statements_equal_ignoring_comments(existing, &disjunct, ScalarEquality::Exact)
}) {
collect_comments(&disjunct, comments);
continue;
}
unique.push(disjunct);
}
unique
}
fn collect_comments(statement: &AstStatement, comments: &mut Vec<AstStatement>) {
match statement {
AstStatement::Comment { .. } => comments.push(statement.clone()),
AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
}
| AstStatement::Item {
value: AstValue::Block { items, .. },
..
} => {
for item in items {
collect_comments(item, comments);
}
}
AstStatement::Assignment { .. } | AstStatement::Item { .. } => {}
}
}
fn body_to_disjuncts(
body: Vec<AstStatement>,
comments: &mut Vec<AstStatement>,
) -> Vec<AstStatement> {
let body = take_comments(body, comments);
if body.len() == 1
&& let Some(items) = boolean_block_body(&body[0], "OR")
{
return items
.into_iter()
.flat_map(|item| body_to_disjuncts(vec![item], comments))
.collect();
}
if body.is_empty() {
return Vec::new();
}
vec![body_to_disjunct(body)]
}
fn take_comments(body: Vec<AstStatement>, comments: &mut Vec<AstStatement>) -> Vec<AstStatement> {
let mut content = Vec::with_capacity(body.len());
for statement in body {
if matches!(statement, AstStatement::Comment { .. }) {
comments.push(statement);
} else {
content.push(statement);
}
}
content
}
fn body_to_disjunct(mut body: Vec<AstStatement>) -> AstStatement {
if body.len() == 1 {
body.pop().expect("single statement checked")
} else {
make_boolean_block("AND", body)
}
}
fn boolean_block_body(statement: &AstStatement, expected_key: &str) -> Option<Vec<AstStatement>> {
match statement {
AstStatement::Assignment {
key,
value: AstValue::Block { items, .. },
..
} if key == expected_key => Some(items.clone()),
_ => None,
}
}
fn make_boolean_block(key: &str, items: Vec<AstStatement>) -> AstStatement {
AstStatement::Assignment {
key: key.to_string(),
key_span: synthetic_span(),
value: AstValue::Block {
items,
span: synthetic_span(),
},
span: synthetic_span(),
}
}
fn synthetic_span() -> SpanRange {
let zero = Span {
line: 0,
column: 0,
offset: 0,
};
SpanRange {
start: zero.clone(),
end: zero,
}
}
#[cfg(test)]
mod tests {
use crate::game::eu4::script::emit::emit_clausewitz_statements;
use crate::game::eu4::script::parser::{AstStatement, parse_clausewitz_content};
use super::{canonical_boolean_or_body, simplify_boolean_or_body};
fn body(source: &str) -> Vec<AstStatement> {
let parsed = parse_clausewitz_content(
&crate::model::GamePathBuf::parse("test.txt").expect("valid game path"),
source,
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
parsed.ast.statements
}
fn emit(statements: &[AstStatement]) -> String {
emit_clausewitz_statements(statements).expect("emit")
}
fn without_comments(statements: Vec<AstStatement>) -> Vec<AstStatement> {
statements
.into_iter()
.filter(|statement| !matches!(statement, AstStatement::Comment { .. }))
.collect()
}
#[test]
fn canonicalization_shape_ignores_comments() {
let plain = canonical_boolean_or_body(body("a = yes\n"));
let commented = canonical_boolean_or_body(body("# note\na = yes\n"));
assert_eq!(
emit(&plain),
emit(&without_comments(commented.clone())),
"comment-only difference must not change the canonical shape"
);
assert!(
emit(&commented).contains("# note"),
"the comment itself must survive: {}",
emit(&commented)
);
}
#[test]
fn canonicalization_of_a_comment_only_body_emits_no_disjunction() {
let canonical = canonical_boolean_or_body(body("# only a note\n"));
let rendered = emit(&canonical);
assert!(!rendered.contains("OR"), "{rendered}");
assert!(rendered.contains("# only a note"), "{rendered}");
}
#[test]
fn canonicalization_keeps_both_spellings_of_a_quoted_scalar() {
let left = canonical_boolean_or_body(body("OR = {\n\ttag = SWE\n\ttag = \"SWE\"\n}\n"));
let right = canonical_boolean_or_body(body("OR = {\n\ttag = \"SWE\"\n\ttag = SWE\n}\n"));
assert_eq!(emit(&left).matches("tag =").count(), 2, "{}", emit(&left));
assert_eq!(
emit(&left).matches("tag =").count(),
emit(&right).matches("tag =").count(),
"both orderings must keep the same disjuncts"
);
}
#[test]
fn canonicalization_still_drops_an_identical_disjunct() {
let canonical = canonical_boolean_or_body(body("OR = {\n\ttag = SWE\n\ttag = SWE\n}\n"));
assert_eq!(
emit(&canonical).matches("tag = SWE").count(),
1,
"{}",
emit(&canonical)
);
}
#[test]
fn canonicalization_keeps_comments_from_a_discarded_duplicate() {
let canonical = canonical_boolean_or_body(body(
"OR = {\n\tAND = {\n\t\t# first\n\t\ta = yes\n\t\tb = yes\n\t}\n\tAND = {\n\t\t# second\n\t\ta = yes\n\t\tb = yes\n\t}\n}\n",
));
let rendered = emit(&canonical);
assert!(rendered.contains("# first"), "{rendered}");
assert!(rendered.contains("# second"), "{rendered}");
assert_eq!(
rendered.matches("a = yes").count(),
1,
"the duplicate disjunct itself must still collapse: {rendered}"
);
}
#[test]
fn simplification_preserves_a_comment_inside_an_unwrapped_conjunction() {
let simplified = simplify_boolean_or_body(body("# note\na = yes\nb = yes\n"));
let rendered = emit(&simplified);
assert!(rendered.contains("# note"), "{rendered}");
assert!(rendered.contains("a = yes"), "{rendered}");
assert!(rendered.contains("b = yes"), "{rendered}");
}
}