Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
9.2 kB · 256 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257//! 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<Entry>, pub warnings: Vec<Warning>,}
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<String> { 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<String> { 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::<i32>().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)); }}