diff --git a/markup.py b/markup.py index 220ca34..3ae381c 100755 --- a/markup.py +++ b/markup.py @@ -1,22 +1,22 @@ #!/usr/bin/env python3 # # Copyright (c) 2022 Alek Ratzloff -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by # the Free Software Foundation, version 3. # -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # -# You should have received a copy of the GNU General Public License +# You should have received a copy of the GNU General Public License # along with this program. If not, see . +import dataclasses import functools import random -import re import string from typing import Optional, Protocol @@ -34,6 +34,25 @@ ESCAPES = { } +@dataclasses.dataclass +class Pos: + source: int + col: int + line: int + + def __str__(self) -> str: + return f"line {self.line} col {self.col}" + + +class MarkupError(Exception): + def __init__(self, where: Pos, message: str): + self.where = where + self.message = message + + def __str__(self) -> str: + return f"{self.where}: {self.message}" + + class MarkupCallable(Protocol): """ Callable for macros. @@ -52,7 +71,7 @@ class MarkupContext: def get_macro(self, name: str) -> MarkupCallable: if name not in self.macros: - raise Exception(f"Could not find macro with name `{name}`") + raise Exception(f"could not find macro with name `{name}`") return self.macros[name] @@ -83,13 +102,16 @@ class MarkupMacro(MarkupExpr): class MarkupParser: def __init__(self, text: str): self.text = text - self.pos = 0 + self.pos = Pos(source=0, col=1, line=1) self.parts: list[MarkupExpr] = [] @property def c(self) -> str: return self.ahead(1) + def ahead(self, count: int) -> str: + return self.text[self.pos.source : self.pos.source + count] + def render(self, ctx: MarkupContext) -> str: self.parse() return "".join([exp.render(ctx) for exp in self.parts]) @@ -121,15 +143,16 @@ class MarkupParser: return MarkupMacro(macro, args) def parse_expr(self) -> MarkupExpr: - if self.c in ["\"", "\'"]: + if self.c in ['"', "'"]: return MarkupRaw(self.parse_macro_string()) elif self.c in NAME_CHARS: return MarkupRaw(self.parse_ident()) elif self.is_match("${"): return self.parse_macro() else: - raise Exception( - "lost the thread around {self.text[self.pos:self.pos + 5]}..." + raise MarkupError( + self.pos, + f"expected expression (string, ident, or `${{`) but got character {self.c} instead", ) def parse_macro_string(self) -> str: @@ -137,29 +160,31 @@ class MarkupParser: if self.c in ['"', "'"]: self.adv() else: - raise Exception("expected single quote (\") or double quote (')") + raise MarkupError( + self.pos, "expected single quote (\") or double quote (')" + ) text = "" while self.c and self.c != start: if self.c == "\\": self.adv() - if self.c in "nrbt\\'\"": + if self.c in ESCAPES: text += ESCAPES[self.c] self.adv() else: - raise Exception(f"unknown escape `\\{self.c}`") + raise MarkupError(self.pos, f"unknown escape `\\{self.c}`") else: text += self.c self.adv() if not self.c: - raise Exception("unterminated string") + raise MarkupError(self.pos, "unterminated string") self.adv() return text def parse_ident(self) -> str: - start = self.pos + start = self.pos.source while self.c and self.c in NAME_CHARS: self.adv() - return self.text[start : self.pos] + return self.text[start : self.pos.source] def skip_whitespace(self): while self.c and self.c in string.whitespace: @@ -172,17 +197,21 @@ class MarkupParser: self.adv() return MarkupRaw(text) - def ahead(self, count: int) -> str: - return self.text[self.pos : self.pos + count] - def adv(self, n: int = 1): - self.pos += n + # only advance if there's characters left to consume + if not self.c: + return + for i in range(n): + if self.c == "\n": + self.pos.line += 1 + self.pos.col = 1 + self.pos.source += 1 def match(self, c: str): if self.is_match(c): self.adv(len(c)) else: - raise Exception(f"expected `{c}`") + raise MarkupError(self.pos, f"expected `{c}`") def is_match(self, c: str) -> bool: if c == self.ahead(len(c)): @@ -210,7 +239,10 @@ def render(text: str, ctx: Optional[MarkupContext] = None) -> str: def macro_random( - ctx: MarkupContext, first: Optional[str] = None, second: Optional[str] = None, *tail: str + ctx: MarkupContext, + first: Optional[str] = None, + second: Optional[str] = None, + *tail: str, ) -> str: """ Choose a random number. @@ -248,7 +280,7 @@ def macro_round(ctx: MarkupContext, n: str, digits: Optional[str] = None) -> str def macro_cat(ctx: MarkupContext, *text: str) -> str: "Concatenates all arguments." - return ''.join(text) + return "".join(text) @functools.cache @@ -266,9 +298,10 @@ def default_context(): ################################################################################ # Main ################################################################################ -if __name__ == '__main__': +if __name__ == "__main__": import sys - filename = '' + + filename = "" if len(sys.argv) == 1: text = sys.stdin.read() else: @@ -277,6 +310,6 @@ if __name__ == '__main__': text = fp.read() try: result = render(text) - print(result, end='') + print(result, end="") except Exception as ex: print(f"{filename}: {ex}")