diff --git a/PFERD/transformer.py b/PFERD/transformer.py new file mode 100644 index 0000000..1ecaf19 --- /dev/null +++ b/PFERD/transformer.py @@ -0,0 +1,238 @@ +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Union + + +class Rule(ABC): + @abstractmethod + def transform(self, path: Path) -> Optional[Path]: + pass + + +class NormalRule(Rule): + def __init__(self, left: Path, right: Path): + self._left = left + self._right = right + + def _match_prefix(self, path: Path) -> Optional[Path]: + left_parts = list(reversed(self._left.parts)) + path_parts = list(reversed(path.parts)) + + if len(left_parts) > len(path_parts): + return None + + while left_parts and path_parts: + left_part = left_parts.pop() + path_part = path_parts.pop() + + if left_part != path_part: + return None + + if left_parts: + return None + + return Path(*path_parts) + + def transform(self, path: Path) -> Optional[Path]: + if rest := self._match_prefix(path): + return self._right / rest + + return None + + +class ExactRule(Rule): + def __init__(self, left: Path, right: Path): + self._left = left + self._right = right + + def transform(self, path: Path) -> Optional[Path]: + if path == self._left: + return self._right + + return None + + +class ReRule(Rule): + def __init__(self, left: str, right: str): + self._left = left + self._right = right + + def transform(self, path: Path) -> Optional[Path]: + if match := re.fullmatch(self._left, str(path)): + kwargs: Dict[str, Union[int, float]] = {} + + groups = [match[0]] + list(match.groups()) + for i, group in enumerate(groups): + try: + kwargs[f"i{i}"] = int(group) + except ValueError: + pass + + try: + kwargs[f"f{i}"] = float(group) + except ValueError: + pass + + return Path(self._right.format(*groups, **kwargs)) + + return None + + +@dataclass +class RuleParseException(Exception): + line: "Line" + reason: str + + def pretty_print(self) -> None: + print(f"Error parsing rule on line {self.line.line_nr}:") + print(self.line.line) + spaces = " " * self.line.index + print(f"{spaces}^--- {self.reason}") + + +class Line: + def __init__(self, line: str, line_nr: int): + self._line = line + self._line_nr = line_nr + self._index = 0 + + def get(self) -> Optional[str]: + if self._index < len(self._line): + return self._line[self._index] + + return None + + @property + def line(self) -> str: + return self._line + + @property + def line_nr(self) -> str: + return self._line + + @property + def index(self) -> int: + return self._index + + @index.setter + def index(self, index: int) -> None: + self._index = index + + def advance(self) -> None: + self._index += 1 + + def expect(self, string: str) -> None: + for char in string: + if self.get() == char: + self.advance() + else: + raise RuleParseException(self, f"Expected {char!r}") + + +QUOTATION_MARKS = {'"', "'"} + + +def parse_string_literal(line: Line) -> str: + escaped = False + result = [] + + quotation_mark = line.get() + if quotation_mark not in QUOTATION_MARKS: + # This should never happen as long as this function is only called from + # parse_string. + raise RuleParseException(line, "Invalid quotation mark") + line.advance() + + while c := line.get(): + if escaped: + result.append(c) + escaped = False + line.advance() + elif c == quotation_mark: + line.advance() + return "".join(result) + elif c == "\\": + escaped = True + line.advance() + else: + result.append(c) + line.advance() + + raise RuleParseException(line, "Expected end of string literal") + + +def parse_until_space_or_eol(line: Line) -> str: + result = [] + while c := line.get(): + if c == " ": + break + result.append(c) + line.advance() + + return "".join(result) + + +def parse_string(line: Line) -> str: + if line.get() in QUOTATION_MARKS: + return parse_string_literal(line) + else: + return parse_until_space_or_eol(line) + + +def parse_arrow(line: Line) -> str: + line.expect("-") + + name = [] + while True: + if c := line.get(): + if c == "-": + break + else: + name.append(c) + line.advance() + else: + raise RuleParseException(line, "Expected rest of arrow") + + line.expect("->") + return "".join(name) + + +def parse_rule(line: Line) -> Rule: + left = parse_string(line) + line.expect(" ") + arrowindex = line.index + arrowname = parse_arrow(line) + line.expect(" ") + right = parse_string(line) + + if arrowname == "": + return NormalRule(Path(left), Path(right)) + elif arrowname == "exact": + return ExactRule(Path(left), Path(right)) + elif arrowname == "re": + return ReRule(left, right) + else: + line.index = arrowindex + 1 # For nicer error message + raise RuleParseException(line, "Invalid arrow name") + + +class Transformer: + def __init__(self, rules: str): + """ + May throw a RuleParseException. + """ + + self._rules = [] + for i, line in enumerate(rules.split("\n")): + line = line.strip() + if line: + self._rules.append(parse_rule(Line(line, i))) + + def transform(self, path: Path) -> Optional[Path]: + for rule in self._rules: + if result := rule.transform(path): + return result + + return None