sots-re/verify/parsers/mars_data.py

211 lines
6.8 KiB
Python

"""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<ws>\s+)
| (?P<comment>//[^\n]*)
| (?P<open>\{)
| (?P<close>\})
| (?P<quoted>"[^"]*")
| (?P<bad_quote>")
| (?P<bare>[^\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)