//! The lexical layer: a `.mms` file as the ordered list of `key=value` lines //! MegaMek reads it as. //! //! This is deliberately not a struct of known fields. `ScenarioV1.load()` puts //! every line into a multimap keyed by the text left of the first `=`, and //! everything above it — factions, units, per-unit damage — is that multimap //! queried by constructed key names. A parser that skipped this layer would //! have to decide what a key means before it has read the `Factions=` line //! that says, and would lose the lines it did not recognise. //! //! Reproduced from `megamek/common/scenario/ScenarioV1.java` (0.51.0), which //! is the only specification there is. /// One `key=value` line, in file order. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { pub key: String, pub value: String, /// 1-based line number in the source, for error messages. pub line: usize, } /// A line MegaMek reads and we could not make sense of. It logs and carries /// on, so these are never fatal — but a generator wants to know. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Warning { pub line: usize, pub text: String, pub message: &'static str, } /// A parsed `.mms` file, still purely lexical. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Document { pub entries: Vec, pub warnings: Vec, } impl Document { /// Read a file the way `ScenarioV1.load()` does. /// /// Blank lines and comment lines are dropped, where a comment is a line /// whose *trimmed* form starts with `#` — so an indented comment counts, /// and a `#` after any other character does not start one. A line with no /// `=` is an error MegaMek logs and skips; it becomes a warning here. Key /// and value are both trimmed, and the split is on the first `=` only, so /// a value may contain any number more. /// /// A key may repeat. MegaMek keeps every value in order and joins them /// with `,` on read, which is load-bearing: `Damage` on one unit is /// written as repeated lines. pub fn parse(text: &str) -> Document { let mut doc = Document::default(); for (i, raw) in text.lines().enumerate() { let line = i + 1; // MegaMek strips a UTF-8 BOM nowhere, but Files.readAllLines // decodes it into the first line's leading char, where it would // become part of the first key. Dropping it is the difference // between reading MMSVersion and not. let raw = if line == 1 { raw.strip_prefix('\u{feff}').unwrap_or(raw) } else { raw }; // Java's String.isBlank() and our trim() agree on whitespace for // every character these files contain. let trimmed = raw.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } match raw.split_once('=') { Some((key, value)) => doc.entries.push(Entry { key: key.trim().to_string(), value: value.trim().to_string(), line, }), None => doc.warnings.push(Warning { line, text: trimmed.to_string(), message: "no '=' in the line; MegaMek logs this and skips it", }), } } doc } /// Every value for a key, in file order. pub fn values(&self, key: &str) -> Vec<&str> { self.entries .iter() .filter(|e| e.key == key) .map(|e| e.value.as_str()) .collect() } /// What `getString(key)` returns: the values joined with `,`, or `None` /// when the key is absent. /// /// The join is why `Factions=A,B` and two `Factions=` lines are the same /// thing to MegaMek, and why a repeated key is not an error. pub fn get(&self, key: &str) -> Option { let values = self.values(key); if values.is_empty() { None } else { Some(values.join(",")) } } /// `getString(key, separator)`, for the one caller that wants a different /// join: pilot advantages are space-separated. pub fn get_joined(&self, key: &str, separator: &str) -> Option { let values = self.values(key); if values.is_empty() { None } else { Some(values.join(separator)) } } /// `parseInt(key, default)`. MegaMek's `MathUtility.parseInt` swallows a /// bad number and returns the default, so a typo reads as "not set" /// rather than as an error. pub fn get_int(&self, key: &str, default: i32) -> i32 { self.get(key) .and_then(|v| v.trim().parse::().ok()) .unwrap_or(default) } /// `parseBoolean`: absent means the default; present means `true`, `on` /// or `1`, case-insensitively, and anything else is false. pub fn get_bool(&self, key: &str, default: bool) -> bool { match self.get(key) { None => default, Some(v) => { let v = v.trim(); v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("1") } } } pub fn contains(&self, key: &str) -> bool { self.entries.iter().any(|e| e.key == key) } /// Render back to `.mms` text: one `key=value` per line, in order. /// /// Not a byte-for-byte round trip of a hand-written file — comments and /// blank lines are gone, and spacing around `=` is normalised — but a /// re-parse of the output is equal to the input, which is the property /// that matters and the one `corpus.rs` checks. pub fn render(&self) -> String { let mut out = String::new(); for entry in &self.entries { out.push_str(&entry.key); out.push('='); out.push_str(&entry.value); out.push('\n'); } out } } #[cfg(test)] mod tests { use super::*; #[test] fn splits_on_the_first_equals_only() { let doc = Document::parse("Description=a=b=c\n"); assert_eq!(doc.get("Description").as_deref(), Some("a=b=c")); } #[test] fn trims_around_the_separator() { let doc = Document::parse(" Name = First Run \n"); assert_eq!(doc.get("Name").as_deref(), Some("First Run")); } #[test] fn drops_blank_and_comment_lines_including_indented_ones() { let doc = Document::parse("# a\n\n # b\nName=x\n"); assert_eq!(doc.entries.len(), 1); assert!(doc.warnings.is_empty()); } #[test] fn a_hash_that_is_not_first_does_not_start_a_comment() { let doc = Document::parse("Name=Trial #3\n"); assert_eq!(doc.get("Name").as_deref(), Some("Trial #3")); } #[test] fn a_line_without_an_equals_warns_and_is_skipped() { let doc = Document::parse("Name=x\nnonsense\n"); assert_eq!(doc.entries.len(), 1); assert_eq!(doc.warnings.len(), 1); assert_eq!(doc.warnings[0].line, 2); } #[test] fn repeated_keys_join_with_a_comma() { let doc = Document::parse("Factions=A\nFactions=B\n"); assert_eq!(doc.get("Factions").as_deref(), Some("A,B")); assert_eq!(doc.values("Factions"), vec!["A", "B"]); } #[test] fn a_bom_does_not_become_part_of_the_first_key() { let doc = Document::parse("\u{feff}MMSVersion=1\n"); assert_eq!(doc.get("MMSVersion").as_deref(), Some("1")); } #[test] fn an_empty_value_is_present_and_empty() { let doc = Document::parse("Maps=\n"); assert_eq!(doc.get("Maps").as_deref(), Some("")); assert!(doc.contains("Maps")); } #[test] fn booleans_follow_megameks_vocabulary() { assert!(Document::parse("S=true\n").get_bool("S", false)); assert!(Document::parse("S=ON\n").get_bool("S", false)); assert!(Document::parse("S=1\n").get_bool("S", false)); assert!(!Document::parse("S=yes\n").get_bool("S", true)); assert!(Document::parse("Other=1\n").get_bool("S", true)); } #[test] fn a_bad_integer_reads_as_the_default() { assert_eq!( Document::parse("BoardWidth=wide\n").get_int("BoardWidth", 1), 1 ); } #[test] fn render_round_trips_through_a_reparse() { // Keys and values, not line numbers: rendering drops the comments and // blank lines, so everything after one moves up. That is the whole // difference, and it is why `line` is source position rather than // part of the content. fn pairs(doc: &Document) -> Vec<(&str, &str)> { doc.entries .iter() .map(|e| (e.key.as_str(), e.value.as_str())) .collect() } let doc = Document::parse("# c\nName=x\nFactions=A\nFactions=B\n"); let again = Document::parse(&doc.render()); assert_eq!(pairs(&again), pairs(&doc)); } }