CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 14s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s
434 lines
15 KiB
Python
434 lines
15 KiB
Python
# Copyright 2026 Logan Fick
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Recursive descent placeholder processing for custom commands.
|
|
|
|
This module implements an AST-based pipeline for parsing and evaluating
|
|
placeholders in custom command response templates. The pipeline has two
|
|
stages:
|
|
|
|
1. **Parser**: A recursive descent parser (``_parse``) converts a template
|
|
string into a list of ``Node`` objects (``TextNode`` for literal text,
|
|
``PlaceholderNode`` for ``$(...)`` expressions). Nesting is supported up to
|
|
a configurable maximum depth.
|
|
|
|
2. **Evaluator**: An async tree-walker (``_evaluate``) resolves the AST
|
|
inside-out: children of each ``PlaceholderNode`` are evaluated first, then
|
|
the resulting flat content string is dispatched to the matching handler in
|
|
``placeholder_handlers`` for final resolution.
|
|
|
|
Supported placeholders: ``$(1)``-``$(9)``, ``$(count)``, ``$(count name [mod])``,
|
|
``$(getcount name)``, ``$(user)``, ``$(rand start stop)``, ``$(countdown date)``,
|
|
``$(countup date)``.
|
|
|
|
Example AST::
|
|
|
|
Template: "$(rand $(1) $(2))"
|
|
Parsed: [PlaceholderNode("rand", [PlaceholderNode("1", []),
|
|
TextNode(" "),
|
|
PlaceholderNode("2", [])])]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .types import Command, CounterAccessor
|
|
|
|
from .placeholder_handlers import HANDLERS, PlaceholderError
|
|
|
|
DEFAULT_MAX_DEPTH: int = 4
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TextNode:
|
|
"""A span of literal text that needs no further processing.
|
|
|
|
:param text: The literal text content.
|
|
"""
|
|
|
|
text: str
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PlaceholderNode:
|
|
"""A ``$(name ...)`` placeholder expression.
|
|
|
|
:param name: The placeholder name, extracted as literal text from the
|
|
template (e.g. ``"rand"``, ``"1"``, ``"user"``). Never contains
|
|
nested placeholders.
|
|
:param children: The parsed body content after the name. May contain
|
|
nested ``PlaceholderNode`` instances (for dynamic arguments) or be
|
|
empty for no-argument placeholders like ``$(user)``.
|
|
"""
|
|
|
|
name: str
|
|
children: list[Node]
|
|
|
|
|
|
type Node = TextNode | PlaceholderNode
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PlaceholderContext:
|
|
"""Bundles the runtime state needed to resolve placeholders.
|
|
|
|
:param args_list: Command arguments (``$(1)``-``$(9)`` values).
|
|
:param user_display_name: Display name of the invoking user.
|
|
:param command: Snapshot of the command being executed.
|
|
:param counters: Counter accessor for named counter operations.
|
|
"""
|
|
|
|
args_list: list[str]
|
|
user_display_name: str
|
|
command: Command
|
|
counters: CounterAccessor
|
|
|
|
|
|
# The parser uses recursive descent to convert a template string into a list
|
|
# of Node objects. It handles escaping (``\$(...)``), nesting (``$(rand
|
|
# $(1) $(2))``), unclosed placeholders (degraded to literal text), and a
|
|
# configurable maximum nesting depth.
|
|
|
|
|
|
def _find_matching_close(template: str, pos: int) -> int:
|
|
r"""Find the position of the ``)`` that closes an escaped ``\$(...)`` group.
|
|
|
|
Tracks nested ``$(`` / ``)`` pairs so that escaped groups containing inner
|
|
placeholders (e.g. ``\$(rand $(1) $(2))``) are consumed in their entirety.
|
|
|
|
:param template: The full template string.
|
|
:param pos: The position immediately after the opening ``$(`` of the
|
|
escaped group (i.e. the first character of the content).
|
|
:return: The index of the matching ``)``, or ``-1`` if not found.
|
|
"""
|
|
depth = 1
|
|
i = pos
|
|
length = len(template)
|
|
while i < length:
|
|
if template[i : i + 2] == "$(":
|
|
depth += 1
|
|
i += 2
|
|
elif template[i] == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
return -1
|
|
|
|
|
|
def _parse_placeholder(
|
|
template: str,
|
|
pos: int,
|
|
depth: int,
|
|
max_depth: int,
|
|
) -> tuple[PlaceholderNode, int] | None:
|
|
"""Parse a single placeholder after the opening ``$(`` has been consumed.
|
|
|
|
Reads the placeholder name (any characters up to a space, ``)``, ``$``,
|
|
or end-of-string), then parses children if the name is followed by a space.
|
|
|
|
:param template: The full template string.
|
|
:param pos: Position immediately after ``$(`` (start of the name).
|
|
:param depth: Current nesting depth.
|
|
:param max_depth: Maximum allowed nesting depth.
|
|
:return: A ``(PlaceholderNode, new_pos)`` tuple, or ``None`` if the
|
|
placeholder is invalid (e.g. empty name).
|
|
"""
|
|
length = len(template)
|
|
|
|
# Read the placeholder name: any characters up to a delimiter.
|
|
name_start = pos
|
|
while pos < length and template[pos] not in (" ", ")", "$"):
|
|
pos += 1
|
|
|
|
name = template[name_start:pos]
|
|
|
|
# Empty name (e.g. $() or $($(...))). Degrade to literal.
|
|
if not name:
|
|
return None
|
|
|
|
# No arguments: immediate close or end of string.
|
|
if pos >= length:
|
|
# Unclosed placeholder at end of string. Return None so the
|
|
# caller degrades "$(" to literal text; the name characters will
|
|
# be re-scanned as literals since the caller's pos only advances
|
|
# past "$(".
|
|
return None
|
|
|
|
if template[pos] == ")":
|
|
# $(name) -- no children.
|
|
return PlaceholderNode(name, []), pos + 1
|
|
|
|
if template[pos] == " ":
|
|
# $(name ... ) -- parse children after the space delimiter.
|
|
child_nodes, new_pos, found_close = _parse_nodes(
|
|
template,
|
|
pos + 1,
|
|
depth + 1,
|
|
inside_placeholder=True,
|
|
max_depth=max_depth,
|
|
)
|
|
if found_close:
|
|
return PlaceholderNode(name, child_nodes), new_pos
|
|
# Unclosed placeholder, degrade to literal. Return None so
|
|
# the caller emits "$(" as literal and re-scans the rest.
|
|
return None
|
|
|
|
# The character after the name is something unexpected (e.g. another $).
|
|
# Treat as unclosed/invalid -- degrade.
|
|
if template[pos : pos + 2] == "$(":
|
|
# Something like $(name$(...)) with no space. Degrade.
|
|
return None
|
|
|
|
return None
|
|
|
|
|
|
def _parse_nodes(
|
|
template: str,
|
|
pos: int,
|
|
depth: int,
|
|
*,
|
|
inside_placeholder: bool,
|
|
max_depth: int,
|
|
) -> tuple[list[Node], int, bool]:
|
|
"""Core recursive parser loop.
|
|
|
|
Scans *template* starting at *pos*, accumulating literal characters and
|
|
recognising ``$(...)`` placeholder openings. Uses :meth:`str.find` to
|
|
skip over literal spans in a single C-level call rather than iterating
|
|
character-by-character.
|
|
|
|
:param template: The full template string.
|
|
:param pos: Current scan position.
|
|
:param depth: Current nesting depth (0 = top level).
|
|
:param inside_placeholder: ``True`` when parsing the children of a
|
|
``PlaceholderNode`` -- a bare ``)`` closes the current group.
|
|
:param max_depth: Maximum allowed nesting depth.
|
|
:return: A 3-tuple ``(nodes, new_pos, found_close)`` where *found_close*
|
|
is ``True`` if scanning stopped because a matching ``)`` was found.
|
|
"""
|
|
nodes: list[Node] = []
|
|
buf: list[str] = []
|
|
length = len(template)
|
|
|
|
def flush_buffer() -> None:
|
|
"""Flush accumulated literal characters as a TextNode."""
|
|
if buf:
|
|
nodes.append(TextNode("".join(buf)))
|
|
buf.clear()
|
|
|
|
while pos < length:
|
|
# Find the next $ (potential placeholder or escape trigger) and
|
|
# ) (potential placeholder close) using C-level str.find to skip
|
|
# over literal text without per-character Python overhead.
|
|
dollar = template.find("$", pos)
|
|
close = template.find(")", pos) if inside_placeholder else -1
|
|
|
|
# Nothing special left -- rest is literal.
|
|
if dollar == -1 and close == -1:
|
|
buf.append(template[pos:])
|
|
pos = length
|
|
break
|
|
|
|
# Normalise -1 to length for min comparison.
|
|
d = dollar if dollar != -1 else length
|
|
c = close if close != -1 else length
|
|
|
|
# Closing paren before next $ -- close the current placeholder group.
|
|
if c < d:
|
|
if c > pos:
|
|
buf.append(template[pos:c])
|
|
flush_buffer()
|
|
return nodes, c + 1, True
|
|
|
|
# Escaped placeholder: \$(...) becomes literal text.
|
|
if (
|
|
dollar > 0
|
|
and dollar - 1 >= pos
|
|
and template[dollar - 1] == "\\"
|
|
and dollar + 1 < length
|
|
and template[dollar + 1] == "("
|
|
):
|
|
# Bulk-append literal text before the backslash.
|
|
if dollar - 1 > pos:
|
|
buf.append(template[pos : dollar - 1])
|
|
# Find the matching close paren, accounting for inner $( pairs.
|
|
match_close = _find_matching_close(template, dollar + 2)
|
|
if match_close == -1:
|
|
# No matching close, treat everything from \ onward as literal.
|
|
buf.append(template[dollar - 1 :])
|
|
pos = length
|
|
else:
|
|
# Emit the content (without the leading backslash) as literal.
|
|
buf.append(template[dollar : match_close + 1])
|
|
pos = match_close + 1
|
|
continue
|
|
|
|
# Placeholder opening: $(
|
|
if dollar + 1 < length and template[dollar + 1] == "(":
|
|
# Bulk-append literal text before the $(.
|
|
if dollar > pos:
|
|
buf.append(template[pos:dollar])
|
|
pos = dollar
|
|
|
|
# If we've hit the nesting limit, treat $( as literal text.
|
|
if depth >= max_depth:
|
|
buf.append("$(")
|
|
pos += 2
|
|
continue
|
|
|
|
flush_buffer()
|
|
|
|
# Delegate to _parse_placeholder for name extraction and children.
|
|
result = _parse_placeholder(template, pos + 2, depth, max_depth)
|
|
if result is None:
|
|
# Failed to parse a valid placeholder (empty name, etc.).
|
|
# Degrade the $( to literal text and continue scanning.
|
|
buf.append("$(")
|
|
pos += 2
|
|
else:
|
|
node, pos = result
|
|
nodes.append(node)
|
|
continue
|
|
|
|
# Lone $ not followed by ( -- bulk-append up to and including it.
|
|
buf.append(template[pos : dollar + 1])
|
|
pos = dollar + 1
|
|
|
|
flush_buffer()
|
|
return nodes, pos, False
|
|
|
|
|
|
@functools.lru_cache(maxsize=256)
|
|
def _parse(template: str, max_depth: int = DEFAULT_MAX_DEPTH) -> list[Node]:
|
|
"""Parse a template string into an AST of ``Node`` objects.
|
|
|
|
This is the entry point for the parser stage. Results are cached by
|
|
``(template, max_depth)`` so repeated invocations of the same command
|
|
skip parsing entirely. The evaluator never mutates the returned AST,
|
|
so sharing cached nodes across calls is safe.
|
|
|
|
:param template: The response template with placeholders.
|
|
:param max_depth: Maximum nesting depth for placeholders. ``$(`` tokens
|
|
encountered at or beyond this depth are treated as literal text.
|
|
:return: List of top-level nodes.
|
|
|
|
Example::
|
|
|
|
>>> _parse("Hello $(user)!")
|
|
[TextNode("Hello "), PlaceholderNode("user", []), TextNode("!")]
|
|
|
|
>>> _parse("$(rand $(1) $(2))")
|
|
[PlaceholderNode("rand", [PlaceholderNode("1", []),
|
|
TextNode(" "),
|
|
PlaceholderNode("2", [])])]
|
|
"""
|
|
nodes, _, _ = _parse_nodes(
|
|
template, 0, 0, inside_placeholder=False, max_depth=max_depth
|
|
)
|
|
return nodes
|
|
|
|
|
|
# Evaluation proceeds inside-out: for each PlaceholderNode the evaluator first
|
|
# recursively evaluates all children to produce a flat args string, then
|
|
# dispatches to the matching handler from HANDLERS.
|
|
|
|
|
|
async def _evaluate_placeholder(
|
|
node: PlaceholderNode,
|
|
ctx: PlaceholderContext,
|
|
) -> str:
|
|
"""Evaluate a single ``PlaceholderNode``.
|
|
|
|
Children are evaluated first (inside-out) and the resulting string is
|
|
split on whitespace to form the argument list. The handler for the
|
|
placeholder name is then looked up and called with those arguments.
|
|
|
|
:param node: The placeholder node to evaluate.
|
|
:param ctx: Runtime context for placeholder resolution.
|
|
:return: The resolved replacement string.
|
|
"""
|
|
name = node.name.lower()
|
|
if node.children:
|
|
args_str = await _evaluate(node.children, ctx)
|
|
args = args_str.split()
|
|
else:
|
|
args = []
|
|
|
|
handler = HANDLERS.get(name)
|
|
if handler is None:
|
|
content = name + (" " + " ".join(args) if args else "")
|
|
return f"$({content})"
|
|
|
|
return await handler(name, args, ctx)
|
|
|
|
|
|
async def _evaluate(nodes: list[Node], ctx: PlaceholderContext) -> str:
|
|
"""Walk the AST and produce the final output string.
|
|
|
|
:param nodes: List of parsed nodes from ``_parse``.
|
|
:param ctx: Runtime context for placeholder resolution.
|
|
:return: The fully resolved string.
|
|
"""
|
|
parts: list[str] = []
|
|
for node in nodes:
|
|
if isinstance(node, TextNode):
|
|
parts.append(node.text)
|
|
else:
|
|
parts.append(await _evaluate_placeholder(node, ctx))
|
|
return "".join(parts)
|
|
|
|
|
|
async def process_placeholders(
|
|
template: str,
|
|
args_list: list[str],
|
|
user_display_name: str,
|
|
command: Command,
|
|
counters: CounterAccessor,
|
|
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
) -> str:
|
|
"""Replace placeholders in a response template.
|
|
|
|
Parses the template into an AST, then evaluates it to produce the final
|
|
output string with all placeholders resolved.
|
|
|
|
If any placeholder raises :exc:`PlaceholderError`, evaluation stops
|
|
immediately and the error message is returned as the entire response.
|
|
|
|
:param template: The response template with placeholders.
|
|
:param args_list: List of arguments passed to the command.
|
|
:param user_display_name: The executing user's display name.
|
|
:param command: Snapshot of the command being executed.
|
|
:param counters: Counter accessor for named counter operations.
|
|
:param max_depth: Maximum nesting depth for placeholders.
|
|
:return: The processed response string, or the error message on failure.
|
|
"""
|
|
nodes = _parse(template, max_depth=max_depth)
|
|
ctx = PlaceholderContext(
|
|
args_list=args_list,
|
|
user_display_name=user_display_name,
|
|
command=command,
|
|
counters=counters,
|
|
)
|
|
try:
|
|
return await _evaluate(nodes, ctx)
|
|
except PlaceholderError as e:
|
|
return str(e)
|