import smp.builtins import traceback import inspect from typing import Any from enum import Enum from io import StringIO from contextlib import redirect_stdout class ParserState(Enum): NORMAL = 1 IN_QUOTES = 2 IN_MACRO = 3 IN_MACRO_ARGS = 4 IN_SPECIAL_MACRO = 5 IN_SPECIAL_MACRO_EXPRESSION = 6 IN_CODE = 7 DNL = 8 def macro_is_whitespace_deleting(s: str) -> bool: if len(s) == 0: return False return s[-1] == "_" def macro_name_clean(macro_name: str) -> str: if macro_is_whitespace_deleting(macro_name): macro_name = macro_name[:-1] return macro_name class MacroProcessor: """All currently defined macros in this MacroProcessor""" macros: dict[str, Any] """ All macro invocations that has happened """ macro_invocations: list[tuple[str, list[str]]] """ Emitted warnings """ warnings: list[Any] """ Global environment for python execution """ py_global_env: dict special_macros: dict[str, tuple[Any, Any]] def __init__(self, prefix=""): self.macros = dict() self.macro_invocations = list() self.warnings = list() self.py_global_env = dict() self._define_builtins(prefix=prefix) def _define_builtins(self, prefix=""): self.macros[f"{prefix}define"] = smp.builtins.smp_builtin_define self.macros[f"{prefix}undefine"] = smp.builtins.smp_builtin_undefine self.macros[f"{prefix}define_array"] = smp.builtins.smp_builtin_define_array self.macros[f"{prefix}ifdef"] = smp.builtins.smp_builtin_ifdef self.macros[f"{prefix}ifndef"] = smp.builtins.smp_builtin_ifndef self.macros[f"{prefix}ifeq"] = smp.builtins.smp_builtin_ifeq self.macros[f"{prefix}ifneq"] = smp.builtins.smp_builtin_ifneq self.macros[f"{prefix}include"] = smp.builtins.smp_builtin_include self.macros[f"{prefix}include_verbatim"] = ( smp.builtins.smp_builtin_include_verbatim ) self.macros[f"{prefix}shell"] = smp.builtins.smp_builtin_shell self.macros[f"{prefix}dumpenv"] = smp.builtins.smp_builtin_dumpenv self.macros[f"{prefix}eval"] = smp.builtins.smp_builtin_eval self.macros[f"{prefix}array_push"] = smp.builtins.smp_builtin_array_push self.macros[f"{prefix}array_each"] = smp.builtins.smp_builtin_array_each self.macros[f"{prefix}array_size"] = smp.builtins.smp_builtin_array_size self.macros[f"{prefix}explode"] = smp.builtins.smp_builtin_explode self.macros[f"{prefix}format_time"] = smp.builtins.smp_builtin_format_time self.macros[f"{prefix}html_from_markdown"] = ( smp.builtins.smp_builtin_html_from_markdown ) self.macros[f"{prefix}wodl"] = smp.builtins.smp_builtin_wodl def expand_macro(self, macro_name: str, args: list[str] = list()) -> str: # Ignore trailing underscore in macro name, the parser will pop a space in front if # present, but we should ignore it for finding the macro. macro_name = macro_name_clean(macro_name) if macro_name not in self.macros: if len(args) == 0: return macro_name out = f"{macro_name}(" for i, arg in enumerate(args): out += self.process_input(arg) if i < (len(args) - 1): out += "," out += ")" return out # Strip leading whitespace from arguments for arg in args: arg = arg.strip() # Log macro invokation # The fact that we are here, does not ensure that the macro is actually expanded into # something useful, just that it exists, and was invoked self.macro_invocations.append((macro_name, args)) macro = self.macros.get(macro_name) if callable(macro): signature = inspect.signature(macro) macro_args = [] if ( "macro_processor" in signature.parameters or "smp" in signature.parameters ): macro_args.append(self) macro_args.extend(args) return str(macro(*macro_args)) if isinstance(macro, str): expanded = macro for i, arg in enumerate(args): placeholder = f"${i}" expanded = macro.replace(placeholder, arg) return self.process_input(expanded) return f"{repr(macro)}" def process_input(self, input: str): """ I also want to add special syntax for "special blocks", I am thinking of two main options, either some macro_names are intercepted, _or_ a special kind of macro can exist like These will be on a line-basis, so they simply end on newline @if @else @endif @for @endfor """ output = "" state = ParserState.NORMAL macro_name = "" macro_args = [] argument = "" py_expr = "" skip_next_line_ending = False # We should keep track of filename, linenumber, and character number on line here # So we can give sensible error messages quote_level = 0 parens_level = 0 i = 0 while i < len(input): c = input[i] peek = None if i + 1 >= len(input) else input[i + 1] # import sys # print(f"[{i:4}] {repr(c):4} -> {repr(peek):4} [{state}] = {repr(output)}", file=sys.stderr) if state == ParserState.DNL: if c == "\n": state = ParserState.NORMAL elif state == ParserState.NORMAL: if skip_next_line_ending and (c == "\n"): skip_next_line_ending = False i += 1 continue if c == "%" and peek == "(": state = ParserState.IN_CODE i += 2 continue if c == "%" and peek == '"': state = ParserState.IN_QUOTES quote_level += 1 i += 1 elif c.isalnum(): state = ParserState.IN_MACRO macro_name += c else: output += c elif state == ParserState.IN_QUOTES: if c == "%" and peek == '"': quote_level += 1 i += 1 output += '%"' elif c == '"' and peek == "%": quote_level -= 1 if quote_level == 0: state = ParserState.NORMAL else: output += '"%' i += 1 else: output += c elif state == ParserState.IN_MACRO: if c.isalnum() or c == "_": macro_name += c elif c == "(": parens_level += 1 state = ParserState.IN_MACRO_ARGS else: if macro_is_whitespace_deleting(macro_name): if output[-1] == " ": output = output[:-1] macro_name = macro_name_clean(macro_name) if macro_name == "SNNL": skip_next_line_ending = c != "\n" elif macro_name == "DNL": if c != "\n": state = ParserState.DNL macro_name = "" i += 1 continue else: expanded = self.expand_macro(macro_name) output += expanded output += c macro_name = "" state = ParserState.NORMAL elif state == ParserState.IN_MACRO_ARGS: if c == "%" and peek == '"': quote_level += 1 i += 2 argument += '%"' continue elif c == '"' and peek == "%": quote_level -= 1 i += 2 argument += '"%' continue elif quote_level > 0: argument += c i += 1 continue if (c == ")") and (parens_level == 1): if macro_is_whitespace_deleting(macro_name): if output[-1] == " ": output = output[:-1] macro_name = macro_name_clean(macro_name) parens_level = 0 macro_args.append(argument.strip()) expanded = self.expand_macro(macro_name, macro_args) output += expanded state = ParserState.NORMAL macro_name = "" macro_args = [] argument = "" elif (c == ",") and (parens_level == 1): macro_args.append(argument.strip()) argument = "" else: if c == "(": parens_level += 1 if c == ")": parens_level -= 1 argument += c elif state == ParserState.IN_CODE: if c == ")" and peek == "%": try: f = StringIO() with redirect_stdout(f): exec(py_expr, self.py_global_env, self.macros) s = f.getvalue() if s != "": output += s except Exception: traceback.print_exc() py_expr = "" state = ParserState.NORMAL i += 1 else: py_expr += c i += 1 # Handle cases where the text ends with a macro without arguments if macro_name != "": if macro_is_whitespace_deleting(macro_name): if output[-1] == " ": output = output[:-1] macro_name = macro_name_clean(macro_name) output += self.expand_macro(macro_name) return output