From c6a18bcce7b4ce40449b883ae96945b01a7dc2ba Mon Sep 17 00:00:00 2001 From: Alek Ratzloff Date: Thu, 22 Sep 2022 10:11:37 -0700 Subject: [PATCH] Add markup.py Signed-off-by: Alek Ratzloff --- markup.py | 271 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100755 markup.py diff --git a/markup.py b/markup.py new file mode 100755 index 0000000..6fe22c2 --- /dev/null +++ b/markup.py @@ -0,0 +1,271 @@ +#!/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 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.c in r"\$": + mac = self.parse_macro() + self.parts += [mac] + else: + 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: MarkupContext | None = 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: str | None = None, second: str | None = 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: str | None = 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}")