Add some location information to exceptions

New parser errors come with location information where the parse failure
occurred

Also, some formatting

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2022-09-24 15:08:14 -07:00
parent 872914f8bd
commit 2e8c1187a7

View File

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