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}")