Files
Bubberstation/tools/maplint/source/dmm.py
Tastyfish 3c8e9594fa [MISSED MIRROR] Maplint tool now has proper github action error messages (#72920) (#19065)
Maplint tool now has proper github action error messages (#72920)

## About The Pull Request

The tool added in #72372 is pretty awesome. The output is uhh cryptic
though. I had to read the source code to realize the (line 382) or
whatever part of the message was the dmm line number and there's stack
traces everywhere. I've made it support github action error messages so
now you get this beauty if you mess up:

![Example cable
error](https://user-images.githubusercontent.com/1185434/214156870-d73ffba0-f79a-43ed-9574-e74cc2ee2057.png)

Or, in the run summary:


![image](https://user-images.githubusercontent.com/1185434/214157201-e392a6d6-a8a8-4d8a-ac74-c65ae97438c8.png)

Errors parsing the lint yml's will also output github action errors,
although the line number will always be 1 since the yaml parser discards
line numbers to my knowledge.

In the midst of doing this, I made the error type contain the file and
line info, and added a bunch of type hints in the midst of trying to
understand Mothblock's code.

Note that for power users, the default behavior is still colored
terminal text; `--github` is added by the CI suite to enable this
behavior.
## Why It's Good For The Game

Much easier to see where the errors are and what they are (who even
knows what a 'pop' is? The tg game code calls them grid models.)
## Changelog
Nothing player-facing.
2023-02-03 17:07:35 -05:00

185 lines
5.8 KiB
Python

# I know we already have one in mapmerge, but this one can afford to be significantly simpler to interface with
# by virtue of being read-only.
import re
from dataclasses import dataclass, field
from typing import IO
from .common import Constant, Filename, Null, Typepath
from .error import MapParseError, MaplintError
REGEX_POP_ID = re.compile(r'^"(?P<key>.+)" = \($')
REGEX_POP_CONTENT_HEADER = re.compile(r'^(?P<path>[/\w]+?)(?P<end>[{,)])$')
REGEX_ROW_BEGIN = re.compile(r'^\((?P<x>\d+),(?P<y>\d+),(?P<z>\d+)\) = {"$')
REGEX_VAR_EDIT = re.compile(r'^\t(?P<name>.+?) = (?P<definition>.+?);?$')
@dataclass
class Content:
path: Typepath
filename: str
starting_line: int
var_edits: dict[str, Constant] = field(default_factory = dict)
@dataclass
class DMM:
pops: dict[str, list[Content]] = field(default_factory = dict)
# Z -> X -> Y -> Pop
turfs: list[list[list[str]]] = field(default_factory = list)
def size(self):
return (len(self.turfs[0]), len(self.turfs[0][0]))
def turfs_for_pop(self, key: str):
for z, z_level in enumerate(self.turfs):
for x, x_level in enumerate(z_level):
for y, turf in enumerate(x_level):
if turf == key:
yield (x, y, z)
class DMMParser:
dmm: DMM
line = 0
def __init__(self, reader: IO):
self.dmm = DMM()
self.reader = reader
def parse(self):
if "dmm2tgm" not in self.next_line():
self.raise_error("Map isn't in TGM format. Consider using StrongDMM instead of Dream Maker.\n Please also consider installing the map merge tools, found through Install.bat in the tools/hooks folder.")
try:
while self.parse_pop():
pass
while self.parse_row():
pass
except MapParseError as error:
raise self.raise_error(error)
return self.dmm
def next_line(self):
self.line += 1
try:
return next(self.reader).removesuffix("\n")
except StopIteration:
return None
def parse_pop(self):
line = self.next_line()
if line == "":
return False
pop_match = REGEX_POP_ID.match(line)
if pop_match is None:
self.raise_error("Pops ended too early, expected a newline in between.")
pop_key = pop_match.group("key")
contents = []
while next_line := self.next_line():
next_line = next_line.rstrip()
content_match = REGEX_POP_CONTENT_HEADER.match(next_line)
if content_match is None:
self.raise_error("Pop content didn't lead to a path")
content = Content(Typepath(content_match.group("path")), self.reader.name, self.line)
contents.append(content)
content_end = content_match.group("end")
if content_end == ")":
break
elif content_end == "{":
while (var_edit := self.parse_var_edit()) is not None:
content.var_edits[var_edit[0]] = var_edit[1]
elif content_end == ",":
continue
self.dmm.pops[pop_key] = contents
return True
def parse_var_edit(self):
line = self.next_line()
if line == "\t},":
return None
var_edit_match = REGEX_VAR_EDIT.match(line)
self.expect(var_edit_match is not None, "Var edits ended too early, expected a newline in between.")
return (var_edit_match.group("name"), self.parse_constant(var_edit_match.group("definition")))
def parse_constant(self, constant):
if (float_constant := self.safe_float(constant)) is not None:
return float_constant
elif re.match(r'^/[/\w]+$', constant):
return Typepath(constant)
elif re.match(r'^".*"$', constant):
# This should do escaping in the future
return constant[1:-1]
elif re.match(r'^null$', constant):
return Null()
elif re.match(r"^'.*'$", constant):
return Filename(constant[1:-1])
elif (list_match := re.match(r'^list\((?P<contents>.*)\)$', constant)):
return ["NYI: list"]
else:
self.raise_error(f"Unknown constant type: {constant}")
def parse_row(self):
line = self.next_line()
if line is None:
return False
if line == "":
# Starting a new z level
return True
row_match = REGEX_ROW_BEGIN.match(line)
self.expect(row_match is not None, "Rows ended too early, expected a newline in between.")
self.expect(row_match.group("y") == "1", "TGM should only be producing individual rows.")
x = int(row_match.group("x")) - 1
z = int(row_match.group("z")) - 1
if len(self.dmm.turfs) <= z:
self.dmm.turfs.append([])
self.expect(len(self.dmm.turfs) == z + 1, "Z coordinate is not sequential")
z_level = self.dmm.turfs[z]
self.expect(len(z_level) == x, "X coordinate is not sequential")
contents = []
while (next_line := self.next_line()) is not None:
next_line = next_line.rstrip()
if next_line == '"}':
break
self.expect(next_line in self.dmm.pops, f"Pop {next_line} is not defined")
contents.append(next_line)
z_level.append(contents)
return True
def safe_float(self, value):
try:
return float(value)
except ValueError:
return None
def expect(self, condition, message):
if not condition:
self.raise_error(message)
def raise_error(self, message):
raise MaplintError(message, self.reader.name, self.line)
def parse_dmm(reader: IO):
return DMMParser(reader).parse()