foch 0.0.2

EU4 mod analysis and merge toolkit
Documentation
use std::collections::BTreeMap;

use crate::game::eu4::content::{MergeKeySource, MergePolicies, NamedContainerPolicy};
use crate::game::eu4::script::parser::{AstStatement, AstValue, ScalarValue, Span, SpanRange};

use crate::merge::semantic_fingerprint::{statement_fingerprint, value_fingerprint};

const SCROLL_LAYER_PREFIX: &str = "foch_scroll_layer_";
const SCROLL_LAYER_HEIGHT: i64 = 1000;
const SCROLL_VIEWPORT_WIDTH: i64 = 1000;
const SCROLL_VIEWPORT_HEIGHT: i64 = 600;
const SCROLL_BAR_SPRITE: &str = "standardlistbox_slider";

pub(crate) fn scroll_stack_variant_identity(
	policies: &MergePolicies,
	key: &str,
	value: &AstValue,
) -> Option<String> {
	let name = scroll_stack_name(policies, key, value)?;
	Some(format!("{key}:{name}:{}", value_fingerprint(value)))
}

pub(crate) fn coalesce_scroll_stack_variants(
	statements: &mut Vec<AstStatement>,
	policies: &MergePolicies,
) {
	if policies.named_container != NamedContainerPolicy::ScrollStack {
		return;
	}
	for statement in statements.iter_mut() {
		if let AstStatement::Assignment {
			value: AstValue::Block { items, .. },
			..
		} = statement
		{
			coalesce_scroll_stack_variants(items, policies);
		}
	}

	let mut positions = BTreeMap::<(String, String), usize>::new();
	let mut coalesced = Vec::with_capacity(statements.len());
	for statement in std::mem::take(statements) {
		let identity = match &statement {
			AstStatement::Assignment { key, value, .. } => {
				scroll_stack_name(policies, key, value).map(|name| (key.clone(), name))
			}
			AstStatement::Item { .. } | AstStatement::Comment { .. } => None,
		};
		let Some(identity) = identity else {
			coalesced.push(statement);
			continue;
		};
		let Some(index) = positions.get(&identity).copied() else {
			positions.insert(identity, coalesced.len());
			coalesced.push(statement);
			continue;
		};
		if statement_fingerprint(&coalesced[index]) == statement_fingerprint(&statement) {
			continue;
		}
		if let Some(stacked) = synthesize_scroll_stack(&coalesced[index], &statement) {
			coalesced[index] = stacked;
		} else {
			coalesced.push(statement);
		}
	}
	*statements = coalesced;
}

fn scroll_stack_name(policies: &MergePolicies, key: &str, value: &AstValue) -> Option<String> {
	if policies.named_container != NamedContainerPolicy::ScrollStack {
		return None;
	}
	let MergeKeySource::ChildFieldValue {
		child_key_field,
		child_types,
	} = policies.nested_merge_key_source
	else {
		return None;
	};
	if !child_types.is_empty() && !child_types.contains(&key) {
		return None;
	}
	scalar_field(value, child_key_field)
}

fn scalar_field(value: &AstValue, field: &str) -> Option<String> {
	let AstValue::Block { items, .. } = value else {
		return None;
	};
	items.iter().find_map(|statement| match statement {
		AstStatement::Assignment {
			key,
			value: AstValue::Scalar { value, .. },
			..
		} if key == field => Some(value.as_text()),
		AstStatement::Assignment { .. }
		| AstStatement::Item { .. }
		| AstStatement::Comment { .. } => None,
	})
}

fn synthetic_span() -> SpanRange {
	let zero = Span {
		line: 0,
		column: 0,
		offset: 0,
	};
	SpanRange {
		start: zero.clone(),
		end: zero,
	}
}

fn scalar_assignment(key: &str, value: ScalarValue) -> AstStatement {
	AstStatement::Assignment {
		key: key.to_string(),
		key_span: synthetic_span(),
		value: AstValue::Scalar {
			value,
			span: synthetic_span(),
		},
		span: synthetic_span(),
	}
}

fn block_assignment(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 xy_block(key: &str, x: i64, y: i64) -> AstStatement {
	block_assignment(
		key,
		vec![
			scalar_assignment("x", ScalarValue::Number(x.to_string())),
			scalar_assignment("y", ScalarValue::Number(y.to_string())),
		],
	)
}

fn body_name(body: &[AstStatement]) -> Option<String> {
	body.iter().find_map(|statement| match statement {
		AstStatement::Assignment {
			key,
			value: AstValue::Scalar { value, .. },
			..
		} if key == "name" => Some(value.as_text()),
		AstStatement::Assignment { .. }
		| AstStatement::Item { .. }
		| AstStatement::Comment { .. } => None,
	})
}

fn strip_name(body: &[AstStatement]) -> Vec<AstStatement> {
	body.iter()
		.filter(
			|statement| !matches!(statement, AstStatement::Assignment { key, .. } if key == "name"),
		)
		.cloned()
		.collect()
}

fn is_scroll_stack_body(body: &[AstStatement]) -> bool {
	body.iter().any(|statement| match statement {
		AstStatement::Assignment {
			key,
			value: AstValue::Block { items, .. },
			..
		} if key == "containerWindowType" => {
			body_name(items).is_some_and(|name| name.starts_with(SCROLL_LAYER_PREFIX))
		}
		_ => false,
	})
}

fn make_layer(index: usize, widgets: Vec<AstStatement>) -> AstStatement {
	let mut items = vec![
		scalar_assignment(
			"name",
			ScalarValue::String(format!("{SCROLL_LAYER_PREFIX}{index}")),
		),
		xy_block("position", 0, index as i64 * SCROLL_LAYER_HEIGHT),
	];
	items.extend(widgets);
	block_assignment("containerWindowType", items)
}

pub(crate) fn synthesize_scroll_stack(
	existing: &AstStatement,
	incoming: &AstStatement,
) -> Option<AstStatement> {
	let (key, existing_body) = match existing {
		AstStatement::Assignment {
			key,
			value: AstValue::Block { items, .. },
			..
		} => (key.clone(), items),
		_ => return None,
	};
	let AstStatement::Assignment {
		value: AstValue::Block {
			items: incoming_body,
			..
		},
		..
	} = incoming
	else {
		return None;
	};
	let name = body_name(existing_body)?;
	let mut parent = Vec::new();
	let mut layers = Vec::new();
	if is_scroll_stack_body(existing_body) {
		let existing_layers = existing_body
			.iter()
			.filter(|statement| {
				matches!(statement, AstStatement::Assignment { key, .. } if key == "containerWindowType")
			})
			.count();
		for statement in existing_body {
			match statement {
				AstStatement::Assignment { key, .. } if key == "containerWindowType" => {
					layers.push(statement.clone());
				}
				AstStatement::Assignment { key, .. }
					if key == "size" || key == "verticalScrollbar" => {}
				other => parent.push(other.clone()),
			}
		}
		layers.push(make_layer(existing_layers, strip_name(incoming_body)));
	} else {
		parent.push(scalar_assignment("name", ScalarValue::String(name)));
		layers.push(make_layer(0, strip_name(existing_body)));
		layers.push(make_layer(1, strip_name(incoming_body)));
	}
	parent.push(xy_block(
		"size",
		SCROLL_VIEWPORT_WIDTH,
		SCROLL_VIEWPORT_HEIGHT,
	));
	parent.push(scalar_assignment(
		"verticalScrollbar",
		ScalarValue::String(SCROLL_BAR_SPRITE.to_string()),
	));
	parent.extend(layers);
	Some(block_assignment(&key, parent))
}

#[cfg(test)]
mod tests {
	use super::*;

	fn policies() -> MergePolicies {
		MergePolicies {
			nested_merge_key_source: MergeKeySource::ChildFieldValue {
				child_key_field: "name",
				child_types: &["windowType"],
			},
			named_container: NamedContainerPolicy::ScrollStack,
			..MergePolicies::default()
		}
	}

	fn name(value: &str) -> AstStatement {
		scalar_assignment("name", ScalarValue::String(value.to_string()))
	}

	fn widget(id: &str, icon: &str) -> AstStatement {
		block_assignment(
			"windowType",
			vec![name(id), block_assignment("iconType", vec![name(icon)])],
		)
	}

	fn collect_names(statements: &[AstStatement], output: &mut Vec<String>) {
		for statement in statements {
			if let AstStatement::Assignment { key, value, .. } = statement {
				if key == "name"
					&& let AstValue::Scalar { value, .. } = value
				{
					output.push(value.as_text());
				}
				if let AstValue::Block { items, .. } = value {
					collect_names(items, output);
				}
			}
		}
	}

	fn names_in(statement: &AstStatement) -> Vec<String> {
		let mut output = Vec::new();
		collect_names(std::slice::from_ref(statement), &mut output);
		output
	}

	#[test]
	fn synthesize_scroll_stack_keeps_both_bodies_lossless() {
		let stacked = synthesize_scroll_stack(&widget("X", "icon_a"), &widget("X", "icon_b"))
			.expect("scroll stack");
		let names = names_in(&stacked);
		for expected in ["X", "icon_a", "icon_b"] {
			assert!(
				names.contains(&expected.to_string()),
				"{expected}: {names:?}"
			);
		}
		assert_eq!(
			names
				.iter()
				.filter(|name| name.starts_with(SCROLL_LAYER_PREFIX))
				.count(),
			2,
		);
	}

	#[test]
	fn coalesce_scroll_stack_variants_appends_contributors_flat() {
		let mut statements = vec![
			widget("X", "icon_a"),
			widget("X", "icon_b"),
			widget("X", "icon_c"),
		];

		coalesce_scroll_stack_variants(&mut statements, &policies());

		assert_eq!(statements.len(), 1);
		let names = names_in(&statements[0]);
		for expected in ["icon_a", "icon_b", "icon_c"] {
			assert!(
				names.contains(&expected.to_string()),
				"{expected}: {names:?}"
			);
		}
		assert_eq!(
			names
				.iter()
				.filter(|name| name.starts_with(SCROLL_LAYER_PREFIX))
				.count(),
			3,
		);
	}
}