Files
markup/markup.py

283 lines
7.6 KiB
Python
Raw Normal View History

#!/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
from typing import Optional, Protocol
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:
if self.is_match("${"):
mac = self.parse_macro()
self.parts += [mac]
else:
# skip past the escape
if self.is_match(r"\${"):
self.adv(3)
self.parts += [MarkupRaw("${")]
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
################################################################################
def render(text: str, ctx: Optional[MarkupContext] = None) -> str:
if not ctx:
ctx = default_context()
assert ctx
parser = MarkupParser(text)
return parser.render(ctx)
################################################################################
# Bulitin functions
################################################################################
def macro_random(
ctx: MarkupContext, first: Optional[str] = None, second: Optional[str] = None, *tail: str
) -> 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)
def macro_round(ctx: MarkupContext, n: str, digits: Optional[str] = None) -> str:
"round() function, same as Python's."
if digits:
res = round(float(n), int(digits))
else:
res = round(float(n))
return str(res)
def macro_cat(ctx: MarkupContext, *text: str) -> str:
"Concatenates all arguments."
return ''.join(text)
@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)
c.register_macro("cat", macro_cat)
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}")