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
|
#!/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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user