# ABOUT ME # Minimal parser for Cosmoteer ".rules" files. # # Why this exists: a Cosmoteer mod patches the game by naming paths into vanilla # .rules data (e.g. "/Part/Components/ # HeatDissipationConverter"). A single wrong path is not caught until the game # crashes on load, with only a log file to go on. This parser resolves those # paths offline so the test suite can prove every target exists before launch. # # Scope: structure only -- named groups, lists, and the raw text of scalar # values. It does not evaluate the "&" reference expressions, arithmetic, or # the ":" inheritance chains, because the tests only need to know that a node # exists and which keys it declares. import re _TOKEN = re.compile( r""" (?P"(?:[^"\\]|\\.)*") # quoted string | (?P[{}\[\]=:;]) # structural punctuation | (?P,) # separator, carries no meaning | (?P\s+) | (?P[^\s{}\[\]=:;,"]+) # bare word / expression fragment """, re.VERBOSE, ) # Tokens that can only begin a new entry, never continue a scalar value. _ENTRY_STARTERS = ("=", "{", "[", ":") class Node: """A parsed .rules node: a group, a list, or a scalar.""" def __init__(self, kind, value=None): self.kind = kind # "group" | "list" | "scalar" self.children = {} # name (or str index) -> Node self.order = [] # child names in declaration order self.bases = [] # raw text of ":" inheritance sources self.value = value # raw text, for scalars def add(self, name, node): if name not in self.children: self.order.append(name) self.children[name] = node def get(self, path): """Resolve a "/"-separated path. Returns None if any step is missing.""" node = self for part in [p for p in path.split("/") if p]: if node is None or part not in node.children: return None node = node.children[part] return node def __contains__(self, name): return name in self.children def __repr__(self): if self.kind == "scalar": return f"Scalar({self.value!r})" return f"{self.kind.title()}({', '.join(self.order)})" def _strip_comments(text): """Remove // comments, leaving anything inside a quoted string alone.""" out = [] in_string = False i = 0 while i < len(text): ch = text[i] if in_string: if ch == "\\": out.append(text[i : i + 2]) i += 2 continue if ch == '"': in_string = False out.append(ch) elif ch == '"': in_string = True out.append(ch) elif ch == "/" and text[i : i + 2] == "//": while i < len(text) and text[i] != "\n": i += 1 continue else: out.append(ch) i += 1 return "".join(out) def _tokenize(text): tokens = [] for match in _TOKEN.finditer(_strip_comments(text)): if match.lastgroup in ("space", "comma"): continue tokens.append(match.group()) return tokens class _Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None def next(self): token = self.peek() self.pos += 1 return token def parse_group(self, terminator=None): node = Node("group") while True: token = self.peek() if token is None: break if token == terminator: self.next() break if token in ("}", "]"): self.next() break if token == ";": self.next() continue name = self.next().strip('"') self.parse_entry(node, name) return node def parse_list(self): node = Node("list") index = 0 while True: token = self.peek() if token is None or token == "]": self.next() break if token == ";": self.next() continue if token == "{": self.next() node.add(str(index), self.parse_group("}")) elif token == "[": self.next() node.add(str(index), self.parse_list()) else: node.add(str(index), Node("scalar", self.next())) index += 1 return node def parse_entry(self, parent, name): """Parse one "name ..." entry and attach it to parent.""" bases = [] while self.peek() == ":": self.next() bases.append(self.next()) token = self.peek() if token == "{": self.next() child = self.parse_group("}") elif token == "[": self.next() child = self.parse_list() elif token == "=": self.next() child = self.parse_value() else: # A bare name with only inheritance, e.g. "Foo : Bar" with no body. child = Node("group") child.bases = bases parent.add(name, child) def parse_value(self): token = self.peek() if token == "{": self.next() return self.parse_group("}") if token == "[": self.next() return self.parse_list() # Scalar: consume tokens until a structural boundary. Expressions such as # "(&../Foo/MAX) * 2" arrive as several bare words, so collect them all. # # The .rules format has no statement terminator, so the only way to know # a value has ended is to look one token ahead: a bare word followed by # "=", "{", "[" or ":" is the name of the *next* entry, not part of this # value. Without this lookahead, "DescriptionKey = "x"" swallows the # "Resources" that follows it and the rest of the file shifts. parts = [] while True: token = self.peek() if token is None or token in (";", "}", "]", "{", "["): break if parts: lookahead = self.tokens[self.pos + 1] if self.pos + 1 < len(self.tokens) else None if lookahead in _ENTRY_STARTERS: break parts.append(self.next()) return Node("scalar", " ".join(parts)) def parse(text): return _Parser(_tokenize(text)).parse_group() def parse_file(path): with open(path, "r", encoding="utf-8", errors="replace") as handle: return parse(handle.read())