use crate::game::eu4::Eu4;
use crate::input::{FileFilter, InventoryOwner, collect_relative_files_within, walk_within};
use crate::model::{
GamePath, GamePathBuf, LocalisationDefinition, LocalisationDuplicate, ParseIssue,
};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub(crate) struct ParsedLocalisationEntryData {
pub definition: LocalisationDefinition,
}
#[derive(Clone, Debug)]
pub(crate) struct ParsedLocalisationFile {
pub entries: Vec<ParsedLocalisationEntryData>,
pub duplicates: Vec<LocalisationDuplicate>,
pub parse_issues: Vec<ParseIssue>,
}
pub(crate) fn parse_localisation_file(
mod_id: &str,
absolute_path: &Path,
relative_path: &GamePath,
) -> ParsedLocalisationFile {
match fs::read(absolute_path) {
Ok(raw) => parse_localisation_bytes(mod_id, relative_path, &raw),
Err(err) => ParsedLocalisationFile {
entries: Vec::new(),
duplicates: Vec::new(),
parse_issues: vec![ParseIssue {
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: 1,
column: 1,
message: format!("unable to read localisation file: {err}"),
repair: None,
isolation: None,
}],
},
}
}
pub(crate) fn parse_localisation_bytes(
mod_id: &str,
relative_path: &GamePath,
raw: &[u8],
) -> ParsedLocalisationFile {
let mut entries = Vec::new();
let mut duplicates = Vec::new();
let mut parse_issues = Vec::new();
let normalized = match normalize_localisation_source(raw) {
Ok(bytes) => bytes,
Err(message) => {
parse_issues.push(ParseIssue {
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: 1,
column: 1,
message,
repair: None,
isolation: None,
});
return ParsedLocalisationFile {
entries,
duplicates,
parse_issues,
};
}
};
let mut header_seen = false;
let mut saw_active_line = false;
let mut header_issue_emitted = false;
let mut seen_keys = HashMap::<String, usize>::new();
for (line_no, line) in line_slices(&normalized) {
let mut line = line;
if line_no == 1 {
line = trim_prefix(line, &[0xEF, 0xBB, 0xBF]);
}
let trimmed = trim_ascii_start(line);
if trimmed.is_empty() || trimmed.first() == Some(&b'#') {
continue;
}
saw_active_line = true;
if parse_localisation_header_bytes(trimmed).is_some() {
header_seen = true;
seen_keys.clear();
continue;
}
if !header_seen {
if !header_issue_emitted {
parse_issues.push(ParseIssue {
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: line_no,
column: 1,
message: "missing or invalid localisation header".to_string(),
repair: None,
isolation: None,
});
header_issue_emitted = true;
}
continue;
}
let Some(entry) = parse_localisation_entry_bytes(trimmed) else {
parse_issues.push(ParseIssue {
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: line_no,
column: 1,
message: "invalid localisation entry".to_string(),
repair: None,
isolation: None,
});
continue;
};
let key = entry.key;
let column = find_subslice(line, key.as_bytes()).map_or(1, |idx| idx + 1);
let definition = LocalisationDefinition {
key: key.clone(),
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: line_no,
column,
};
if let Some(first_line) = seen_keys.insert(key.clone(), line_no) {
duplicates.push(LocalisationDuplicate {
key,
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
first_line,
duplicate_line: line_no,
});
}
entries.push(ParsedLocalisationEntryData { definition });
}
if saw_active_line && !header_seen && !header_issue_emitted {
parse_issues.push(ParseIssue {
mod_id: mod_id.to_string(),
path: relative_path.to_owned(),
line: 1,
column: 1,
message: "missing localisation header".to_string(),
repair: None,
isolation: None,
});
}
ParsedLocalisationFile {
entries,
duplicates,
parse_issues,
}
}
const LOCALISATION_DIRECTORIES: [&[&str]; 2] = [&["localisation"], &["common", "localisation"]];
pub(crate) fn collect_localisation_definitions_from_root(
mod_id: &str,
root: &Path,
) -> io::Result<Vec<LocalisationDefinition>> {
let files = collect_relative_files_within(
root,
&LOCALISATION_DIRECTORIES,
&FileFilter::for_game(Eu4),
InventoryOwner::Mod(mod_id),
)?;
Ok(localisation_definitions_in_files(mod_id, root, &files))
}
pub fn walk_localisation_files(root: &Path) -> impl Iterator<Item = io::Result<PathBuf>> + '_ {
walk_within(root, &LOCALISATION_DIRECTORIES).filter_map(|entry| match entry {
Ok(entry) => (entry.file_type().is_file() && has_localisation_extension(entry.path()))
.then(|| Ok(entry.into_path())),
Err(error) => Some(Err(error)),
})
}
fn has_localisation_extension(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"))
}
pub fn localisation_definitions_in_files(
mod_id: &str,
root: &Path,
files: &[GamePathBuf],
) -> Vec<LocalisationDefinition> {
let filter = FileFilter::for_game(Eu4);
let mut definitions = Vec::new();
for relative in files {
if !filter.accepts(relative)
|| !LOCALISATION_DIRECTORIES
.iter()
.any(|directory| relative.is_inside(directory, str::eq))
{
continue;
}
if !relative
.extension()
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "yml" | "yaml"))
{
continue;
}
let parsed = parse_localisation_file(mod_id, &relative.to_path(root), relative);
definitions.extend(parsed.entries.into_iter().map(|item| item.definition));
}
definitions.sort_by(|lhs, rhs| {
(
lhs.path.as_relative_path(),
lhs.line,
lhs.column,
lhs.key.as_str(),
lhs.mod_id.as_str(),
)
.cmp(&(
rhs.path.as_relative_path(),
rhs.line,
rhs.column,
rhs.key.as_str(),
rhs.mod_id.as_str(),
))
});
definitions.dedup_by(|lhs, rhs| {
lhs.path == rhs.path
&& lhs.line == rhs.line
&& lhs.column == rhs.column
&& lhs.key == rhs.key
&& lhs.mod_id == rhs.mod_id
});
definitions
}
struct ParsedLocalisationEntry {
key: String,
}
fn normalize_localisation_source(raw: &[u8]) -> Result<Vec<u8>, String> {
if raw.starts_with(&[0xFF, 0xFE]) {
return decode_utf16_with_bom(&raw[2..], true);
}
if raw.starts_with(&[0xFE, 0xFF]) {
return decode_utf16_with_bom(&raw[2..], false);
}
Ok(raw.to_vec())
}
fn decode_utf16_with_bom(raw: &[u8], little_endian: bool) -> Result<Vec<u8>, String> {
if !raw.len().is_multiple_of(2) {
return Err("invalid utf-16 localisation file".to_string());
}
let mut units = Vec::with_capacity(raw.len() / 2);
for chunk in raw.chunks_exact(2) {
let unit = if little_endian {
u16::from_le_bytes([chunk[0], chunk[1]])
} else {
u16::from_be_bytes([chunk[0], chunk[1]])
};
units.push(unit);
}
String::from_utf16(&units)
.map(|decoded| decoded.into_bytes())
.map_err(|_| "invalid utf-16 localisation file".to_string())
}
fn line_slices(bytes: &[u8]) -> Vec<(usize, &[u8])> {
let mut lines = Vec::new();
let mut start = 0usize;
let mut line_no = 1usize;
for (idx, byte) in bytes.iter().enumerate() {
if *byte == b'\n' {
let mut line = &bytes[start..idx];
if line.ends_with(b"\r") {
line = &line[..line.len() - 1];
}
lines.push((line_no, line));
line_no += 1;
start = idx + 1;
}
}
if start <= bytes.len() {
let mut line = &bytes[start..];
if line.ends_with(b"\r") {
line = &line[..line.len() - 1];
}
if !line.is_empty() || bytes.is_empty() {
lines.push((line_no, line));
}
}
lines
}
fn trim_prefix<'a>(bytes: &'a [u8], prefix: &[u8]) -> &'a [u8] {
bytes.strip_prefix(prefix).unwrap_or(bytes)
}
fn trim_ascii_start(bytes: &[u8]) -> &[u8] {
let idx = bytes
.iter()
.position(|byte| !byte.is_ascii_whitespace())
.unwrap_or(bytes.len());
&bytes[idx..]
}
fn trim_ascii_end(bytes: &[u8]) -> &[u8] {
let idx = bytes
.iter()
.rposition(|byte| !byte.is_ascii_whitespace())
.map_or(0, |idx| idx + 1);
&bytes[..idx]
}
fn parse_localisation_header_bytes(line: &[u8]) -> Option<&str> {
let trimmed = trim_ascii_end(line);
let body = if let Some(idx) = trimmed.iter().position(|byte| *byte == b'#') {
trim_ascii_end(&trimmed[..idx])
} else {
trimmed
};
let body = std::str::from_utf8(body).ok()?;
let lang = body.strip_prefix("l_")?.strip_suffix(':')?;
if lang.is_empty()
|| !lang
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
return None;
}
Some(lang)
}
pub(crate) fn parse_localisation_values(raw: &[u8]) -> Vec<(String, String, String)> {
let Ok(normalized) = normalize_localisation_source(raw) else {
return Vec::new();
};
let mut values = Vec::new();
let mut language: Option<String> = None;
for (line_no, line) in line_slices(&normalized) {
let mut line = line;
if line_no == 1 {
line = trim_prefix(line, &[0xEF, 0xBB, 0xBF]);
}
let trimmed = trim_ascii_start(line);
if trimmed.is_empty() || trimmed.first() == Some(&b'#') {
continue;
}
if let Some(header) = parse_localisation_header_bytes(trimmed) {
language = Some(header.to_owned());
continue;
}
let (Some(language), Some(entry)) = (&language, parse_localisation_entry_bytes(trimmed))
else {
continue;
};
let Some(open) = trimmed.iter().position(|byte| *byte == b'"') else {
continue;
};
let rest = &trimmed[open + 1..];
let value = match rest.iter().rposition(|byte| *byte == b'"') {
Some(close) => &rest[..close],
None => trim_ascii_end(rest),
};
values.push((
language.clone(),
entry.key,
String::from_utf8_lossy(value).into_owned(),
));
}
values
}
fn parse_localisation_entry_bytes(line: &[u8]) -> Option<ParsedLocalisationEntry> {
let trimmed = trim_ascii_start(line);
let colon_idx = trimmed.iter().position(|byte| *byte == b':')?;
let key_bytes = &trimmed[..colon_idx];
if key_bytes.is_empty()
|| !key_bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
{
return None;
}
let key = std::str::from_utf8(key_bytes).ok()?.to_string();
let mut remainder = trim_ascii_start(&trimmed[colon_idx + 1..]);
if remainder.first().is_some_and(|byte| byte.is_ascii_digit()) {
let version_end = remainder
.iter()
.position(|byte| !byte.is_ascii_digit())
.unwrap_or(remainder.len());
remainder = trim_ascii_start(&remainder[version_end..]);
}
let value_start = remainder.iter().position(|byte| *byte == b'"')?;
let _ = value_start;
Some(ParsedLocalisationEntry { key })
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack
.windows(needle.len())
.position(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::{
collect_localisation_definitions_from_root, parse_localisation_bytes,
parse_localisation_file,
};
use crate::model::GamePath;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn write_localisation(root: &Path, files: &[(&str, &str)]) {
for (relative, key) in files {
let path = root.join(relative);
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
fs::write(&path, format!("l_english:\n {key}:0 \"v\"\n")).expect("write file");
}
}
fn collected(root: &Path) -> Vec<(String, String)> {
collect_localisation_definitions_from_root("mod-a", root)
.expect("collect localisation")
.into_iter()
.map(|definition| (definition.path.into_string(), definition.key))
.collect()
}
#[test]
fn definitions_are_collected_from_both_directories_in_component_order() {
let tmp = TempDir::new().expect("temp dir");
write_localisation(
tmp.path(),
&[
("localisation/a-b_l_english.yml", "DASH"),
("localisation/a/b_l_english.yml", "NESTED"),
("common/localisation/x_l_english.yaml", "COMMON"),
("common/x_l_english.yml", "OUTSIDE"),
("events/y_l_english.yml", "OUTSIDE"),
("localisation/notes.txt", "NOT_YAML"),
],
);
assert_eq!(
collected(tmp.path()),
vec![
(
"common/localisation/x_l_english.yaml".to_string(),
"COMMON".to_string()
),
(
"localisation/a/b_l_english.yml".to_string(),
"NESTED".to_string()
),
(
"localisation/a-b_l_english.yml".to_string(),
"DASH".to_string()
),
]
);
}
#[test]
fn localisation_directories_match_as_spelled() {
let tmp = TempDir::new().expect("temp dir");
write_localisation(tmp.path(), &[("Localisation/a_l_english.yml", "UPPER")]);
assert!(collected(tmp.path()).is_empty());
}
#[cfg(unix)]
#[test]
fn an_unportable_localisation_file_name_is_an_error() {
let tmp = TempDir::new().expect("temp dir");
write_localisation(
tmp.path(),
&[
("localisation/a_l_english.yml", "KEPT"),
(r"localisation/a\b_l_english.yml", "UNPORTABLE"),
],
);
let error = collect_localisation_definitions_from_root("mod-a", tmp.path())
.expect_err("a literal backslash has no game path")
.to_string();
assert!(error.starts_with("mod mod-a: "), "{error}");
assert!(
error.contains(&format!("under root {}", tmp.path().display())),
"{error}"
);
assert!(
error.contains(
&tmp.path()
.join("localisation")
.join(r"a\b_l_english.yml")
.display()
.to_string()
),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn an_unportable_name_outside_localisation_does_not_affect_it() {
let tmp = TempDir::new().expect("temp dir");
write_localisation(tmp.path(), &[("localisation/a_l_english.yml", "KEPT")]);
let elsewhere = tmp.path().join("gfx").join(r"a\b.dds");
fs::create_dir_all(elsewhere.parent().expect("parent")).expect("create dir");
fs::write(&elsewhere, b"dds").expect("write file");
assert_eq!(
collected(tmp.path()),
vec![(
"localisation/a_l_english.yml".to_string(),
"KEPT".to_string()
)]
);
}
#[test]
fn parser_accepts_gbk_value_bytes_without_utf8_lossy() {
let tmp = TempDir::new().expect("temp dir");
let path = tmp.path().join("localisation").join("test_l_english.yml");
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let mut bytes = Vec::from(&b"l_english:\n TEST:0 \""[..]);
bytes.extend([0xD6, 0xD0, 0xCE, 0xC4]);
bytes.extend(&b"\"\n"[..]);
fs::write(&path, bytes).expect("write file");
let parsed = parse_localisation_file(
"mod",
&path,
GamePath::new("localisation/test_l_english.yml").expect("valid game path"),
);
assert_eq!(parsed.entries.len(), 1);
assert!(parsed.parse_issues.is_empty(), "{:?}", parsed.parse_issues);
}
#[test]
fn duplicate_detection_resets_per_language_section() {
let tmp = TempDir::new().expect("temp dir");
let path = tmp.path().join("localisation").join("languages.yml");
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let source = "l_english:\n l_english:0 \"English\"\n l_german:0 \"German\"\nl_german:\n l_english:0 \"Englisch\"\n l_german:0 \"Deutsch\"\n";
fs::write(&path, source).expect("write file");
let parsed = parse_localisation_file(
"mod",
&path,
GamePath::new("localisation/languages.yml").expect("valid game path"),
);
assert_eq!(parsed.entries.len(), 4);
assert!(
parsed.duplicates.is_empty(),
"keys reused across sections must not be reported as duplicates: {:?}",
parsed.duplicates,
);
}
#[test]
fn duplicate_detection_still_flags_within_section() {
let tmp = TempDir::new().expect("temp dir");
let path = tmp.path().join("localisation").join("dupes_l_english.yml");
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let source = "l_english:\n FOO:0 \"first\"\n FOO:0 \"second\"\n";
fs::write(&path, source).expect("write file");
let parsed = parse_localisation_file(
"mod",
&path,
GamePath::new("localisation/dupes_l_english.yml").expect("valid game path"),
);
assert_eq!(parsed.duplicates.len(), 1);
assert_eq!(parsed.duplicates[0].key, "FOO");
}
#[test]
fn parser_indexes_entry_with_unclosed_quote() {
let tmp = TempDir::new().expect("temp dir");
let path = tmp
.path()
.join("localisation")
.join("unclosed_l_english.yml");
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let source =
"l_english:\n EVT_5_d: \"Some prose without a closing quote\n EVT_5_a: \"Reply!\"\n";
fs::write(&path, source).expect("write file");
let parsed = parse_localisation_file(
"mod",
&path,
GamePath::new("localisation/unclosed_l_english.yml").expect("valid game path"),
);
let keys: Vec<_> = parsed
.entries
.iter()
.map(|entry| entry.definition.key.clone())
.collect();
assert_eq!(keys, vec!["EVT_5_d".to_string(), "EVT_5_a".to_string()]);
assert!(parsed.parse_issues.is_empty(), "{:?}", parsed.parse_issues);
}
#[test]
fn parser_indexes_entry_with_trailing_prose_after_closing_quote() {
let tmp = TempDir::new().expect("temp dir");
let path = tmp
.path()
.join("localisation")
.join("trailing_l_english.yml");
fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let source = "l_english:\n OPT2: \"Short reply!\" was cut for making the option too long\n";
fs::write(&path, source).expect("write file");
let parsed = parse_localisation_file(
"mod",
&path,
GamePath::new("localisation/trailing_l_english.yml").expect("valid game path"),
);
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].definition.key, "OPT2");
assert!(parsed.parse_issues.is_empty(), "{:?}", parsed.parse_issues);
}
#[test]
fn values_follow_their_language_section_and_tolerate_loose_quotes() {
let values = super::parse_localisation_values(
"\u{feff}l_english:\n a:0 \"Say \"hi\" [Root.GetName]\" # note\n b: \"open ended\nl_german:\n a:0 \"Hallo\"\n"
.as_bytes(),
);
assert_eq!(
values,
[
(
"english".into(),
"a".into(),
"Say \"hi\" [Root.GetName]".into()
),
("english".into(), "b".into(), "open ended".into()),
("german".into(), "a".into(), "Hallo".into()),
]
);
}
#[test]
fn bytes_and_file_parsing_agree() {
let parsed = parse_localisation_bytes(
"mod",
GamePath::new("localisation/a_l_english.yml").expect("game path"),
"\u{feff}l_english:\n a_key:0 \"A\"\n".as_bytes(),
);
assert!(parsed.parse_issues.is_empty());
assert_eq!(parsed.entries[0].definition.key, "a_key");
assert_eq!(parsed.entries[0].definition.line, 2);
}
}