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:
91
markup.py
91
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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 = '<stdin>'
|
||||
|
||||
filename = "<stdin>"
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user