Files
CHOMPStation2/tools/maplint/source/lint.py
2025-02-21 11:48:45 +04:00

355 lines
13 KiB
Python

import re
from typing import Optional
from .common import Constant, Filename, Typepath
from .dmm import DMM, Content
from .error import MaplintError, MapParseError
def expect(condition, message):
if not condition:
raise MapParseError(message)
def fail_content(content: Content, message: str, path_suggestion = "", dm_suggestion = "", dm_sub_suggestion = "") -> MaplintError:
"""Create an error linked to a specific content instance"""
return MaplintError(message, content.filename, content.starting_line, path_suggestion, dm_suggestion, dm_sub_suggestion)
class TypepathExtra:
typepath: Typepath
exact: bool = False
wildcard: bool = False
def __init__(self, typepath):
if typepath == '*':
self.wildcard = True
return
if typepath.startswith('='):
self.exact = True
typepath = typepath[1:]
self.typepath = Typepath(typepath)
def matches_path(self, path: Typepath):
if self.wildcard:
return True
if self.exact:
return self.typepath == path
if len(self.typepath.segments) > len(path.segments):
return False
return self.typepath.segments == path.segments[:len(self.typepath.segments)]
class BannedNeighbor:
identical: bool = False
typepath: Optional[TypepathExtra] = None
pattern: Optional[re.Pattern] = None
def __init__(self, typepath, data = {}):
if typepath.upper() != typepath:
self.typepath = TypepathExtra(typepath)
if data is None:
return
expect(isinstance(data, dict), "Banned neighbor must be a dictionary.")
if "identical" in data:
self.identical = data.pop("identical")
expect(isinstance(self.identical, bool), "identical must be a boolean.")
if "pattern" in data:
self.pattern = re.compile(data.pop("pattern"))
expect(len(data) == 0, f"Unknown key in banned neighbor: {', '.join(data.keys())}.")
def matches(self, identified: Content, neighbor: Content):
if self.identical:
if identified.path != neighbor.path:
return False
if identified.var_edits != neighbor.var_edits:
return False
return True
if self.typepath is not None:
if self.typepath.matches_path(neighbor.path):
return True
if self.pattern is not None:
if self.pattern.match(str(neighbor.path)):
return True
return False
Choices = list[Constant] | re.Pattern
def extract_choices(data, key) -> Optional[Choices]:
if key not in data:
return None
constants_data = data.pop(key)
if isinstance(constants_data, list):
constants: list[Constant] = []
for constant_data in constants_data:
if isinstance(constant_data, str):
constants.append(constant_data)
elif isinstance(constant_data, int):
constants.append(float(constant_data))
elif isinstance(constant_data, float):
constants.append(constant_data)
return constants
elif isinstance(constants_data, dict):
if "pattern" in constants_data:
pattern = constants_data.pop("pattern")
return re.compile(pattern)
raise MapParseError(f"Unknown key in {key}: {', '.join(constants_data.keys())}.")
raise MapParseError(f"{key} must be a list of constants, or a pattern")
class BannedVariable:
variable: str
allow: Optional[Choices] = None
deny: Optional[Choices] = None
def __init__(self, variable, data = {}):
self.variable = variable
if data is None:
return
self.allow = extract_choices(data, "allow")
self.deny = extract_choices(data, "deny")
expect(len(data) == 0, f"Unknown key in banned variable {variable}: {', '.join(data.keys())}.")
def run(self, identified: Content) -> str:
if identified.var_edits[self.variable] is None:
return None
if self.allow is not None:
if isinstance(self.allow, list):
if identified.var_edits[self.variable] not in self.allow:
return f"Must be one of {', '.join(map(str, self.allow))}"
elif not self.allow.match(str(identified.var_edits[self.variable])):
return f"Must match {self.allow.pattern}"
return None
if self.deny is not None:
if isinstance(self.deny, list):
if identified.var_edits[self.variable] in self.deny:
return f"Must not be one of {', '.join(map(str, self.deny))}"
elif self.deny.match(str(identified.var_edits[self.variable])):
return f"Must not match {self.deny.pattern}"
return None
return f"This variable is not allowed for this type."
class Rules:
banned: bool = False
banned_neighbors: list[BannedNeighbor] = []
banned_variables: bool | list[BannedVariable] = []
def __init__(self, data):
expect(isinstance(data, dict), "Lint rules must be a dictionary.")
if "banned" in data:
self.banned = data.pop("banned")
expect(isinstance(self.banned, bool), "banned must be a boolean.")
if "banned_neighbors" in data:
banned_neighbors_data = data.pop("banned_neighbors")
expect(isinstance(banned_neighbors_data, list) or isinstance(banned_neighbors_data, dict), "banned_neighbors must be a list, or a dictionary keyed by type.")
if isinstance(banned_neighbors_data, dict):
self.banned_neighbors = [BannedNeighbor(typepath, data) for typepath, data in banned_neighbors_data.items()]
else:
self.banned_neighbors = [BannedNeighbor(typepath) for typepath in banned_neighbors_data]
if "banned_variables" in data:
banned_variables_data = data.pop("banned_variables")
if banned_variables_data == True:
self.banned_variables = True
else:
expect(isinstance(banned_variables_data, list) or isinstance(banned_variables_data, dict), "banned_variables must be a list, or a dictionary keyed by variable.")
if isinstance(banned_variables_data, dict):
self.banned_variables = [BannedVariable(variable, data) for variable, data in banned_variables_data.items()]
else:
self.banned_variables = [BannedVariable(variable) for variable in banned_variables_data]
expect(len(data) == 0, f"Unknown lint rules: {', '.join(data.keys())}.")
def run(self, identified: Content, contents: list[Content], identified_index) -> list[MaplintError]:
failures: list[MaplintError] = []
if self.banned:
failures.append(fail_content(identified, f"Typepath {identified.path} is banned."))
for banned_neighbor in self.banned_neighbors:
for neighbor in contents[:identified_index] + contents[identified_index + 1:]:
if not banned_neighbor.matches(identified, neighbor):
continue
failures.append(fail_content(identified, f"Typepath {identified.path} has a banned neighbor: {neighbor.path}"))
if self.banned_variables == True:
if len(identified.var_edits) > 0:
path_suggestion, dm_suggestion, dm_sub_suggestion = self.parse_suggestion(identified)
failures.append(fail_content(identified, f"Typepath {identified.path} should not have any variable edits.", path_suggestion, dm_suggestion, dm_sub_suggestion))
else:
assert isinstance(self.banned_variables, list)
for banned_variable in self.banned_variables:
if banned_variable.variable in identified.var_edits:
ban_reason = banned_variable.run(identified)
if ban_reason is None:
continue
failures.append(fail_content(identified, f"Typepath {identified.path} has a banned variable (set to {identified.var_edits[banned_variable.variable]}): {banned_variable.variable}. {ban_reason}"))
return failures
def parse_suggestion(self, identified):
#figure out what typepath we're going to suggest
other_var_count = 0
typepath_suggestion = f"{identified.path}/"
dir_var = ""
for var_name, var_value in identified.var_edits.items():
if(var_name == "dir"):
dir_var = self.parse_direction(var_value)
else:
other_var_count += 1
if(other_var_count > 1):
typepath_suggestion += "_"
typepath_suggestion += f"{var_value}"
#cleanup typepath
typepath_suggestion = typepath_suggestion.replace(" ", "_").replace("-", "_").replace(",", "_")
#always offer a unique dir as a subtype
typepath_dir_suggestion = ""
if(dir_var):
if(other_var_count > 0):
typepath_dir_suggestion = f"{typepath_suggestion}/{dir_var}"
else:
typepath_suggestion += dir_var
#generate suggestion entries
dm_suggestion = f"{typepath_suggestion}\n"
path_suggestion = f"{identified.path}{{"
for var_name, var_value in identified.var_edits.items():
if(var_name == "dir"):
if(typepath_dir_suggestion == ""):
dm_suggestion += f"\t{var_name} = {dir_var.upper()}\n"
path_suggestion += f"{var_name}={var_value};"
elif(isinstance(var_value, Filename)):
dm_suggestion += f"\t{var_name} = \'{var_value.path}\'\n"
path_suggestion += f"{var_name}=\'{var_value.path}\';"
elif(isinstance(var_value, str)):
dm_suggestion += f"\t{var_name} = \"{var_value}\"\n"
path_suggestion += f"{var_name}=\"{var_value}\";"
else:
dm_suggestion += f"\t{var_name} = {var_value}\n"
path_suggestion += f"{var_name}={var_value};"
dm_suggestion += "\n"
if(typepath_dir_suggestion == ""):
path_suggestion += f"}} : {typepath_suggestion}\n"
#generate second dm suggestion if its a dir with other stuff
dm_sub_suggestion = ""
if(typepath_dir_suggestion != ""):
dm_sub_suggestion = dm_suggestion
dm_suggestion = f"{typepath_dir_suggestion}\n"
dm_suggestion += f"\tdir = {dir_var.upper()}\n\n"
path_suggestion += f"}} : {typepath_dir_suggestion}\n"
return path_suggestion, dm_suggestion, dm_sub_suggestion
def parse_direction(self, number):
if(number == 1):
return "north"
if(number == 2):
return "south"
if(number == 4):
return "east"
if(number == 8):
return "west"
if(number == 5):
return "northeast"
if(number == 6):
return "southeast"
if(number == 9):
return "northwest"
if(number == 10):
return "southwest"
if(number == 16):
return "up"
if(number == 32):
return "down"
raise TypeError(f"Unknown direction : {number}")
class Lint:
help: Optional[str] = None
rules: dict[TypepathExtra, Rules]
def __init__(self, data):
expect(isinstance(data, dict), "Lint must be a dictionary.")
if "help" in data:
self.help = data.pop("help")
expect(isinstance(self.help, str) or self.help is None, "Lint help must be a string.")
self.rules = {}
for typepath, rules in data.items():
self.rules[TypepathExtra(typepath)] = Rules(rules)
def run(self, map_data: DMM) -> list[MaplintError]:
all_failures: list[MaplintError] = []
(width, height) = map_data.size()
for pop, contents in map_data.pops.items():
for typepath_extra, rules in self.rules.items():
for content_index, content in enumerate(contents):
if not typepath_extra.matches_path(content.path):
continue
failures = rules.run(content, contents, content_index)
if len(failures) == 0:
continue
coordinates = map_data.turfs_for_pop(pop)
coordinate_texts = []
for _ in range(3):
coordinate = next(coordinates, None)
if coordinate is None:
break
x = coordinate[0] + 1
y = height - coordinate[1]
z = coordinate[2] + 1
coordinate_texts.append(f"({x}, {y}, {z})")
leftover_coordinates = sum(1 for _ in coordinates)
if leftover_coordinates > 0:
coordinate_texts.append(f"and {leftover_coordinates} more")
for failure in failures:
failure.coordinates = ', '.join(coordinate_texts)
failure.help = self.help
failure.pop_id = pop
all_failures.append(failure)
return list(set(all_failures))