211 lines
6.8 KiB
Python
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)
|