AXLR / Catalog / programming

regex_test

Run a Python regular expression over a text and list every match with its groups, named groups and spans, plus an optional substitution preview and split, under a hard time limit. Flags IGNORECASE, MULTILINE, DOTALL, VERBOSE and ASCII; search, match or fullmatch anchoring. A pattern that backtracks catastrophically is stopped by a timer and reported as a timeout instead of hanging; invalid patterns report the error position.

Install

/tools install regex_test
/tools install regex_test@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · regex_test 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool regex_test; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
patternstringyesPython re syntax. length ≤ 10000
textstringyeslength ≤ 200000
flagsarray of stringno[]
anchorstringno"search"search: every match anywhere; match: at the start; fullmatch: the whole text. one of "search", "match", "fullmatch"
max_matchesintegerno1000≥ 1; ≤ 10000
replacementstringnore.sub template (\1, \g<name>) for a substitution preview
countintegerno0Maximum substitutions; 0 = all. ≥ 0
splitbooleannofalseAlso split the text on the pattern
maxsplitintegerno0≥ 0
timeoutnumberno2Seconds before the run is stopped. ≥ 0.1; ≤ 10
Input schema (JSON)
{
  "type": "object",
  "required": [
    "pattern",
    "text"
  ],
  "additionalProperties": false,
  "properties": {
    "pattern": {
      "type": "string",
      "maxLength": 10000,
      "description": "Python re syntax"
    },
    "text": {
      "type": "string",
      "maxLength": 200000
    },
    "flags": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "IGNORECASE",
          "MULTILINE",
          "DOTALL",
          "VERBOSE",
          "ASCII",
          "i",
          "m",
          "s",
          "x",
          "a",
          "I",
          "M",
          "S",
          "X",
          "A"
        ]
      },
      "default": []
    },
    "anchor": {
      "type": "string",
      "enum": [
        "search",
        "match",
        "fullmatch"
      ],
      "default": "search",
      "description": "search: every match anywhere; match: at the start; fullmatch: the whole text"
    },
    "max_matches": {
      "type": "integer",
      "minimum": 1,
      "maximum": 10000,
      "default": 1000
    },
    "replacement": {
      "type": "string",
      "description": "re.sub template (\\1, \\g<name>) for a substitution preview"
    },
    "count": {
      "type": "integer",
      "minimum": 0,
      "default": 0,
      "description": "Maximum substitutions; 0 = all"
    },
    "split": {
      "type": "boolean",
      "default": false,
      "description": "Also split the text on the pattern"
    },
    "maxsplit": {
      "type": "integer",
      "minimum": 0,
      "default": 0
    },
    "timeout": {
      "type": "number",
      "minimum": 0.1,
      "maximum": 10,
      "default": 2,
      "description": "Seconds before the run is stopped"
    }
  }
}

Example

Input

{
  "pattern": "(?P<year>\\d{4})-(?P<month>\\d{2})",
  "text": "from 2026-10 to 2027-01"
}

Output (stdout)

{
  "match_count": 2,
  "matches": [
    {
      "match": "2026-10",
      "span": [
        5,
        12
      ],
      "groups": [
        "2026",
        "10"
      ],
      "named": {
        "year": "2026",
        "month": "10"
      }
    },
    {
      "match": "2027-01",
      "span": [
        16,
        23
      ],
      "groups": [
        "2027",
        "01"
      ],
      "named": {
        "year": "2027",
        "month": "01"
      }
    }
  ]
}

Files

The command the install card registers is python3 regex_test.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

regex_test.py · sha256 f16b3622a8b02f8d99f9a6a85285506b7222283187ad5bf516da2ab19c3a5d3a
#!/usr/bin/env python3
"""Run a Python regular expression over a text: matches with groups, named groups and spans,
a substitution preview and a split, under a hard time limit.

Input (JSON on stdin):
  pattern       the regular expression (Python `re` syntax)
  text          the subject text
  flags         list of IGNORECASE/i, MULTILINE/m, DOTALL/s, VERBOSE/x, ASCII/a (optional)
  anchor        "search" (default: every match anywhere), "match" (at the start) or "fullmatch"
  max_matches   at most this many matches are listed (default 1000, at most 10000)
  replacement   a re.sub template (\\1, \\g<name>) for a substitution preview (optional)
  count         maximum substitutions (default 0 = all)
  split         true: also split the text on the pattern
  maxsplit      maximum splits (default 0 = all)
  timeout       seconds, 0.1 to 10 (default 2)
"""
import json
import re
import signal
import sys

FLAGS = {"IGNORECASE": re.IGNORECASE, "I": re.IGNORECASE, "MULTILINE": re.MULTILINE, "M": re.MULTILINE,
         "DOTALL": re.DOTALL, "S": re.DOTALL, "VERBOSE": re.VERBOSE, "X": re.VERBOSE,
         "ASCII": re.ASCII, "A": re.ASCII}
CANON = {re.IGNORECASE: "IGNORECASE", re.MULTILINE: "MULTILINE", re.DOTALL: "DOTALL", re.VERBOSE: "VERBOSE",
         re.ASCII: "ASCII"}
MAX_PATTERN = 10000
MAX_TEXT = 200_000
KNOWN = {"pattern", "text", "flags", "anchor", "max_matches", "replacement", "count", "split", "maxsplit", "timeout"}


def fail(message):
    print(json.dumps({"error": message}, ensure_ascii=False))
    sys.exit(2)


class Timeout(Exception):
    pass


def on_alarm(signum, frame):
    raise Timeout()


def integer(request, key, default, low, high):
    value = request.get(key, default)
    if not isinstance(value, int) or isinstance(value, bool) or not low <= value <= high:
        fail(f"{key} must be an integer from {low} to {high}")
    return value


def describe(m, names):
    item = {"match": m.group(0), "span": list(m.span()), "groups": list(m.groups()),
            "group_spans": [list(m.span(i)) if m.group(i) is not None else None for i in range(1, (m.re.groups or 0) + 1)]}
    if names:
        item["named"] = m.groupdict()
    return item


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("stdin must be a JSON object")
    extra = sorted(set(request) - KNOWN)
    if extra:
        fail(f"unknown field(s): {', '.join(extra)}")
    pattern, text = request.get("pattern"), request.get("text")
    if not isinstance(pattern, str):
        fail("pattern must be a string")
    if not isinstance(text, str):
        fail("text must be a string")
    if len(pattern) > MAX_PATTERN:
        fail(f"pattern is longer than {MAX_PATTERN} characters")
    if len(text) > MAX_TEXT:
        fail(f"text is longer than {MAX_TEXT} characters")
    flags = request.get("flags", [])
    if not isinstance(flags, list) or not all(isinstance(f, str) for f in flags):
        fail("flags must be a list of flag names")
    value = 0
    for f in flags:
        key = f.upper().replace("RE.", "")
        if key not in FLAGS:
            fail(f"unknown flag {f!r}; use IGNORECASE, MULTILINE, DOTALL, VERBOSE or ASCII (or i, m, s, x, a)")
        value |= FLAGS[key]
    anchor = request.get("anchor", "search")
    if anchor not in ("search", "match", "fullmatch"):
        fail("anchor must be search, match or fullmatch")
    max_matches = integer(request, "max_matches", 1000, 1, 10000)
    count = integer(request, "count", 0, 0, 10_000_000)
    maxsplit = integer(request, "maxsplit", 0, 0, 10_000_000)
    replacement = request.get("replacement")
    if replacement is not None and not isinstance(replacement, str):
        fail("replacement must be a string")
    do_split = request.get("split", False)
    if not isinstance(do_split, bool):
        fail("split must be true or false")
    timeout = request.get("timeout", 2)
    if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.1 <= timeout <= 10:
        fail("timeout must be a number of seconds from 0.1 to 10")

    signal.signal(signal.SIGALRM, on_alarm)
    signal.setitimer(signal.ITIMER_REAL, float(timeout))
    stage = "compiling the pattern"
    try:
        try:
            rx = re.compile(pattern, value)
        except re.error as error:
            where = f" at position {error.pos}" if error.pos is not None else ""
            fail(f"invalid pattern{where}: {error.msg}")
        names = sorted(rx.groupindex, key=rx.groupindex.get)
        stage = "matching"
        matches, truncated = [], False
        if anchor == "search":
            for m in rx.finditer(text):
                if len(matches) == max_matches:
                    truncated = True
                    break
                matches.append(describe(m, names))
        else:
            m = rx.match(text) if anchor == "match" else rx.fullmatch(text)
            if m:
                matches.append(describe(m, names))
        result = {"pattern": pattern, "flags": sorted({CANON[v] for k, v in FLAGS.items() if value & v}),
                  "anchor": anchor, "groups": rx.groups, "group_names": names,
                  "match_count": len(matches), "matched": bool(matches), "matches": matches}
        if truncated:
            result["truncated"] = f"only the first {max_matches} matches are listed"
        if replacement is not None:
            stage = "substituting"
            try:
                new, n = rx.subn(replacement, text, count=count)
            except (re.error, IndexError) as error:
                fail(f"invalid replacement: {error}")
            result["substitution"] = {"replacement": replacement, "result": new, "replacements": n}
        if do_split:
            stage = "splitting"
            result["split"] = rx.split(text, maxsplit=maxsplit)
    except Timeout:
        fail(f"timed out after {timeout} s while {stage}: the pattern probably backtracks catastrophically "
             f"(nested quantifiers such as (a+)+ or (a|a)*); make the inner repetition unambiguous or possessive")
    except (OverflowError, RecursionError, MemoryError) as error:
        fail(f"the pattern is too large to run: {type(error).__name__}")
    finally:
        signal.setitimer(signal.ITIMER_REAL, 0)
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/regex_test/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/regex_test/1.0.0/regex_test.py