2022-09-22 10:11:37 -07:00
|
|
|
#!/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
|
|
|
|
|
# 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
|
|
|
|
|
# General Public License for more details.
|
|
|
|
|
#
|
|
|
|
|
# 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 functools
|
|
|
|
|
import random
|
|
|
|
|
import re
|
|
|
|
|
import string
|
2022-09-22 20:33:03 -07:00
|
|
|
from typing import Optional, Protocol
|
2022-09-22 10:11:37 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
NAME_CHARS = string.ascii_letters + string.digits
|
|
|
|
|
DIGIT_SET = set(string.digits)
|
|
|
|
|
ESCAPES = {
|
|
|
|
|
"n": "\n",
|
|
|
|
|
"r": "\r",
|
|
|
|
|
"b": "\b",
|
|
|
|
|
"t": "\t",
|
|
|
|
|
"\\": "\\",
|
|
|
|
|
"'": "'",
|
|
|
|
|
'"': '"',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupCallable(Protocol):
|
|
|
|
|
"""
|
|
|
|
|
Callable for macros.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __call__(self, ctx: "MarkupContext", *args: str) -> str:
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupContext:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.macros: dict[str, MarkupCallable] = {}
|
|
|
|
|
|
|
|
|
|
def register_macro(self, name: str, callback: MarkupCallable):
|
|
|
|
|
self.macros[name] = callback
|
|
|
|
|
|
|
|
|
|
def get_macro(self, name: str) -> MarkupCallable:
|
|
|
|
|
if name not in self.macros:
|
|
|
|
|
raise Exception(f"Could not find macro with name `{name}`")
|
|
|
|
|
return self.macros[name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupExpr:
|
|
|
|
|
def render(self, ctx: MarkupContext) -> str:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupRaw(MarkupExpr):
|
|
|
|
|
def __init__(self, text: str):
|
|
|
|
|
self.text = text
|
|
|
|
|
|
|
|
|
|
def render(self, ctx: MarkupContext) -> str:
|
|
|
|
|
return self.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupMacro(MarkupExpr):
|
|
|
|
|
def __init__(self, macro: str, args: list[MarkupExpr]):
|
|
|
|
|
self.macro = macro
|
|
|
|
|
self.args = args
|
|
|
|
|
|
|
|
|
|
def render(self, ctx: MarkupContext) -> str:
|
|
|
|
|
macro = ctx.get_macro(self.macro)
|
|
|
|
|
args = [arg.render(ctx) for arg in self.args]
|
|
|
|
|
return macro(ctx, *args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MarkupParser:
|
|
|
|
|
def __init__(self, text: str):
|
|
|
|
|
self.text = text
|
|
|
|
|
self.pos = 0
|
|
|
|
|
self.parts: list[MarkupExpr] = []
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def c(self) -> str:
|
|
|
|
|
return self.ahead(1)
|
|
|
|
|
|
|
|
|
|
def render(self, ctx: MarkupContext) -> str:
|
|
|
|
|
self.parse()
|
|
|
|
|
return "".join([exp.render(ctx) for exp in self.parts])
|
|
|
|
|
|
|
|
|
|
def parse(self):
|
|
|
|
|
while self.c:
|
2022-09-22 10:21:47 -07:00
|
|
|
if self.is_match("${"):
|
2022-09-22 10:11:37 -07:00
|
|
|
mac = self.parse_macro()
|
|
|
|
|
self.parts += [mac]
|
|
|
|
|
else:
|
2022-09-22 10:21:47 -07:00
|
|
|
# skip past the escape
|
|
|
|
|
if self.is_match(r"\${"):
|
|
|
|
|
self.adv(3)
|
|
|
|
|
self.parts += [MarkupRaw("${")]
|
2022-09-22 10:11:37 -07:00
|
|
|
raw = self.parse_raw()
|
|
|
|
|
self.parts += [raw]
|
|
|
|
|
|
|
|
|
|
def parse_macro(self) -> MarkupExpr:
|
|
|
|
|
self.match("${")
|
|
|
|
|
self.skip_whitespace()
|
|
|
|
|
macro = self.parse_ident()
|
|
|
|
|
args = []
|
|
|
|
|
self.skip_whitespace()
|
|
|
|
|
while self.c and self.c != "}":
|
|
|
|
|
arg = self.parse_expr()
|
|
|
|
|
args += [arg]
|
|
|
|
|
self.skip_whitespace()
|
|
|
|
|
self.match("}")
|
|
|
|
|
return MarkupMacro(macro, args)
|
|
|
|
|
|
|
|
|
|
def parse_expr(self) -> MarkupExpr:
|
|
|
|
|
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]}..."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def parse_macro_string(self) -> str:
|
|
|
|
|
start = self.c
|
|
|
|
|
if self.c in ['"', "'"]:
|
|
|
|
|
self.adv()
|
|
|
|
|
else:
|
|
|
|
|
raise Exception("expected single quote (\") or double quote (')")
|
|
|
|
|
text = ""
|
|
|
|
|
while self.c and self.c != start:
|
|
|
|
|
if self.c == "\\":
|
|
|
|
|
self.adv()
|
|
|
|
|
if self.c in "nrbt\\'\"":
|
|
|
|
|
text += ESCAPES[self.c]
|
|
|
|
|
self.adv()
|
|
|
|
|
else:
|
|
|
|
|
raise Exception(f"unknown escape `\\{self.c}`")
|
|
|
|
|
else:
|
|
|
|
|
text += self.c
|
|
|
|
|
self.adv()
|
|
|
|
|
if not self.c:
|
|
|
|
|
raise Exception("unterminated string")
|
|
|
|
|
self.adv()
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
def parse_ident(self) -> str:
|
|
|
|
|
start = self.pos
|
|
|
|
|
while self.c and self.c in NAME_CHARS:
|
|
|
|
|
self.adv()
|
|
|
|
|
return self.text[start : self.pos]
|
|
|
|
|
|
|
|
|
|
def skip_whitespace(self):
|
|
|
|
|
while self.c and self.c in string.whitespace:
|
|
|
|
|
self.adv()
|
|
|
|
|
|
|
|
|
|
def parse_raw(self) -> MarkupExpr:
|
|
|
|
|
text = ""
|
|
|
|
|
while self.c and self.c not in r"\$":
|
|
|
|
|
text += self.c
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
def match(self, c: str):
|
|
|
|
|
if self.is_match(c):
|
|
|
|
|
self.adv(len(c))
|
|
|
|
|
else:
|
|
|
|
|
raise Exception(f"expected `{c}`")
|
|
|
|
|
|
|
|
|
|
def is_match(self, c: str) -> bool:
|
|
|
|
|
if c == self.ahead(len(c)):
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
################################################################################
|
|
|
|
|
# Utility functions
|
|
|
|
|
################################################################################
|
|
|
|
|
|
|
|
|
|
|
2022-09-22 20:33:03 -07:00
|
|
|
def render(text: str, ctx: Optional[MarkupContext] = None) -> str:
|
2022-09-22 10:11:37 -07:00
|
|
|
if not ctx:
|
|
|
|
|
ctx = default_context()
|
|
|
|
|
assert ctx
|
|
|
|
|
parser = MarkupParser(text)
|
|
|
|
|
return parser.render(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
################################################################################
|
|
|
|
|
# Bulitin functions
|
|
|
|
|
################################################################################
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def macro_random(
|
2022-09-22 20:52:21 -07:00
|
|
|
ctx: MarkupContext, first: Optional[str] = None, second: Optional[str] = None, *tail: str
|
2022-09-22 10:11:37 -07:00
|
|
|
) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Choose a random number.
|
|
|
|
|
|
|
|
|
|
If no arguments, chooses a random float between 0.0 and 1.0, exclusive.
|
|
|
|
|
|
|
|
|
|
If one argument, chooses a random integer between 1 and the provided number, inclusive.
|
|
|
|
|
|
|
|
|
|
If two arguments, chooses a random integer between the provided numbers, inclusive.
|
|
|
|
|
"""
|
|
|
|
|
if first and second:
|
|
|
|
|
lo = int(first)
|
|
|
|
|
hi = int(second)
|
|
|
|
|
return str(random.randint(lo, hi))
|
|
|
|
|
elif first:
|
|
|
|
|
hi = int(first)
|
|
|
|
|
return str(random.randint(1, hi))
|
|
|
|
|
else:
|
|
|
|
|
return str(random.random())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def macro_choose(ctx: MarkupContext, *choices: str):
|
|
|
|
|
"Choose a random item."
|
|
|
|
|
return random.choice(choices)
|
|
|
|
|
|
|
|
|
|
|
2022-09-22 20:33:03 -07:00
|
|
|
def macro_round(ctx: MarkupContext, n: str, digits: Optional[str] = None) -> str:
|
2022-09-22 10:11:37 -07:00
|
|
|
"round() function, same as Python's."
|
|
|
|
|
if digits:
|
|
|
|
|
res = round(float(n), int(digits))
|
|
|
|
|
else:
|
|
|
|
|
res = round(float(n))
|
|
|
|
|
return str(res)
|
|
|
|
|
|
|
|
|
|
|
2022-09-22 20:53:27 -07:00
|
|
|
def macro_cat(ctx: MarkupContext, *text: str) -> str:
|
|
|
|
|
"Concatenates all arguments."
|
|
|
|
|
return ''.join(text)
|
|
|
|
|
|
|
|
|
|
|
2022-09-22 10:11:37 -07:00
|
|
|
@functools.cache
|
|
|
|
|
def default_context():
|
|
|
|
|
c = MarkupContext()
|
|
|
|
|
c.register_macro("random", macro_random)
|
|
|
|
|
c.register_macro("choose", macro_choose)
|
|
|
|
|
c.register_macro("round", macro_round)
|
|
|
|
|
|
2022-09-22 20:53:27 -07:00
|
|
|
c.register_macro("cat", macro_cat)
|
|
|
|
|
|
2022-09-22 10:11:37 -07:00
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
################################################################################
|
|
|
|
|
# Main
|
|
|
|
|
################################################################################
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
import sys
|
|
|
|
|
filename = '<stdin>'
|
|
|
|
|
if len(sys.argv) == 1:
|
|
|
|
|
text = sys.stdin.read()
|
|
|
|
|
else:
|
|
|
|
|
filename = sys.argv[1]
|
|
|
|
|
with open(filename) as fp:
|
|
|
|
|
text = fp.read()
|
|
|
|
|
try:
|
|
|
|
|
result = render(text)
|
|
|
|
|
print(result, end='')
|
|
|
|
|
except Exception as ex:
|
|
|
|
|
print(f"{filename}: {ex}")
|