#!/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 . 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: Optiona[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) @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) return c ################################################################################ # Main ################################################################################ if __name__ == '__main__': import sys filename = '' 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}")