"""mars_data.py -- reader for the Mars engine's brace-block key/value format. Covers: *.weapon, *.shipsection, *.tech, *.combat, *.def, *.script and the block-form *.txt files (scenarios, tutorial, credits, systemnames, skydefs, ctechvars, shipai). Grammar (as observed in every shipped SOTS1 file): body := (block | pair | item)* block := NAME '{' body '}' pair := NAME value item := QUOTED # bare quoted string inside a block value := QUOTED | BAREWORD comment := '//' .* EOL A file's top level is itself a body (scenario .txt files mix top-level pairs and player{} blocks; catalog files hold one or many named blocks). Result shape: plain dicts. A key seen once maps to its value; a key seen more than once maps to a list (use get_list() when you want a list always). Bare quoted items are collected under the key "_items". Quirks handled (all seen in the real data, see parsers-report.md): * keys are case-insensitive to the engine ("Requires"/"requires", "badge"/"Badge") -> keys are lower-cased unless keep_case=True * a block may open on the same line as a preceding pair ("turretsize small mount {") and a block name may sit on the same line as its brace ("weapon {") * backslashes inside quoted strings are literal (Windows paths); there is no escape syntax * '//' inside a quoted string is not a comment * CRLF and LF line endings, cp1252 bytes (decoded losslessly) * numbers use C float syntax: ".5", "-.8", "7e+8" Stdlib only. """ from __future__ import annotations import re from typing import Any, Iterator __all__ = ["parse", "parse_file", "coerce", "get_list", "MarsSyntaxError"] class MarsSyntaxError(ValueError): pass # --- tokenizer ------------------------------------------------------------- _TOKEN_RE = re.compile( r""" (?P\s+) | (?P//[^\n]*) | (?P\{) | (?P\}) | (?P"[^"]*") | (?P") | (?P[^\s{}"]+) """, re.VERBOSE, ) def _tokenize(text: str) -> Iterator[tuple[str, str, int]]: """Yield (kind, value, line). kind in {open, close, quoted, bare}.""" line = 1 pos = 0 n = len(text) while pos < n: m = _TOKEN_RE.match(text, pos) if m is None: # pragma: no cover - regex is exhaustive raise MarsSyntaxError(f"line {line}: cannot tokenize {text[pos:pos+20]!r}") kind = m.lastgroup tok = m.group() pos = m.end() if kind == "ws": line += tok.count("\n") continue if kind == "comment": continue if kind == "bad_quote": raise MarsSyntaxError(f"line {line}: unterminated string") if kind == "quoted": yield kind, tok[1:-1], line line += tok.count("\n") else: yield kind, tok, line # --- parser ---------------------------------------------------------------- _INT_RE = re.compile(r"[+-]?\d+$") _FLOAT_RE = re.compile(r"[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$") def coerce(tok: str) -> Any: """Bareword -> int / float / bool when it looks like one, else str.""" if _INT_RE.match(tok): return int(tok) if _FLOAT_RE.match(tok): return float(tok) low = tok.lower() if low == "true": return True if low == "false": return False return tok def _add(d: dict, key: str, value: Any) -> None: if key in d: cur = d[key] if isinstance(cur, list): cur.append(value) else: d[key] = [cur, value] else: d[key] = value def get_list(d: dict, key: str) -> list: """Always return a list for a key (missing -> [], single -> [x]).""" v = d.get(key) if v is None: return [] return v if isinstance(v, list) else [v] class _Parser: def __init__(self, text: str, typed: bool, keep_case: bool, strict: bool, warnings: list | None): self.toks = list(_tokenize(text)) self.i = 0 self.typed = typed self.keep_case = keep_case self.strict = strict self.warnings = warnings if warnings is not None else [] def _warn(self, msg: str) -> None: if self.strict: raise MarsSyntaxError(msg) self.warnings.append(msg) def _peek(self): return self.toks[self.i] if self.i < len(self.toks) else None def _next(self): t = self.toks[self.i] self.i += 1 return t def _key(self, name: str) -> str: return name if self.keep_case else name.lower() def body(self, depth: int) -> dict: d: dict = {} while True: t = self._peek() if t is None: if depth: # 11 shipped shipsections never close their outer block; # the engine treats EOF as closing every open block. self._warn(f"end of file inside block (depth {depth})") return d kind, val, line = t if kind == "close": self._next() if not depth: # CrPropaganda.shipsection has one '}' too many. self._warn(f"line {line}: stray '}}' at top level") continue return d if kind == "open": raise MarsSyntaxError(f"line {line}: '{{' without a block name") self._next() if kind == "quoted": # bare string item (systemnames.txt lists) -- never a key _add(d, "_items", val) continue nxt = self._peek() if nxt is None or nxt[0] == "close": # lone bareword at end of block: treat as flag item _add(d, "_items", val) continue if nxt[0] == "open": self._next() _add(d, self._key(val), self.body(depth + 1)) continue nkind, nval, _ = self._next() if nkind == "bare" and self.typed: nval = coerce(nval) _add(d, self._key(val), nval) def parse(text: str, *, typed: bool = True, keep_case: bool = False, strict: bool = False, warnings: list | None = None) -> dict: """Parse brace-block text into nested dicts. typed -- convert bareword numbers/bools (quoted strings stay str) keep_case -- keep key case instead of lower-casing strict -- raise on unbalanced braces instead of recovering the way the engine does (EOF closes open blocks, stray top-level '}' ignored); pass warnings=[] to collect the recoveries """ return _Parser(text, typed, keep_case, strict, warnings).body(0) def parse_file(path, **kw) -> dict: with open(path, "rb") as f: raw = f.read() return parse(raw.decode("cp1252"), **kw)