Combinators¶
Overview¶
The combinators module provides five composition primitives that build larger
parsers out of scanners and plain callables. The core abstraction:
type Parser[T] = Callable[[str], T]
A scanner is itself a Callable[[str], tuple[...]] — pair it with first_field to
plug it into the combinators. Every combinator treats only MatchError (no match)
as "try the next option"; all other exceptions propagate unchanged.
from infinity_parse import alt, first_field, scan
value = alt(first_field(scan[str]('"{}"')), first_field(scan[str]("{}")))
value('"hi"') # 'hi'
value("hi") # 'hi'
alt — Ordered Choice¶
def alt[T](*parsers: Parser[T]) -> Parser[T]
Tries the parsers in order and returns the first result that does not raise
MatchError:
- Only
MatchErrortriggers "next one";ConvertErrorand friends abort immediately (a hard failure is never swallowed). - When all parsers fail, raises
MatchError("no alternative matched ...")with__cause__pointing at the last failure.
from infinity_parse import ConvertError, MatchError, alt, scan
value = alt(scan[int]("x={}"), scan[int]("y={}"))
value("z=1") # MatchError: no alternative matched 'z=1'
strict = alt(scan[int]("{}"), scan[str]("{}"))
strict("abc") # ConvertError — the int branch matched but failed to convert;
# it does not fall back to str
recursive — Fixpoint (Recursion)¶
def recursive[T](builder: Callable[[Parser[T]], Parser[T]]) -> Parser[T]
builder receives a thunk pointing at the final parser, which lets you build
self-referential grammars:
from infinity_parse import MatchError, Parser, alt, first_field, recursive, scan
def wrapped(build: Parser[str]) -> Parser[str]:
def group(text: str) -> str:
if not (text.startswith("(") and text.endswith(")")):
raise MatchError(f"not wrapped: {text!r}")
return build(text[1:-1])
return alt(group, first_field(scan[str]("{}")))
parse = recursive(wrapped)
parse("(((x)))") # 'x'
parse("x") # 'x'
Nesting depth is bounded by the interpreter recursion limit: roughly 300-350 levels with default settings (about 3 frames per level); very deep grammars need
sys.setrecursionlimit(with the usual stack-overflow caveats).
split_top — Top-Level Splitting¶
def split_top(separator: str, delimiters: tuple[str, str]) -> Parser[list[str]]
Splits a string at top-level separator characters; delimiters (the open and
close character sets) protect separators inside nested text. Returns raw pieces
(no stripping, no parsing):
from infinity_parse import split_top
split_commas = split_top(",", ("[{", "}]"))
split_commas("1, [2, 3], {4, 5}") # ['1', ' [2, 3]', ' {4, 5}']
- Depth is counted per character: any character in the open set
+1, any in the close set-1; only the sets you pass are protected (("[{", "}]")protects[]and{}, not parentheses — pass("([{", ")]}")to include them). - The separator only splits at depth 0; there is no quote awareness.
- Empty input returns
[''].
sep_by — Separated Lists¶
def sep_by[T](parser: Parser[T], spliter: Parser[list[str]]) -> Parser[list[T]]
First splits with spliter (usually from split_top), then applies parser to
each piece:
from infinity_parse import first_field, scan, sep_by, split_top
split_commas = split_top(",", ("[{", "}]"))
numbers = sep_by(first_field(scan[int]("{}")), split_commas)
numbers("1, 2, 3") # [1, 2, 3]
first_field — Adapting Scanners¶
def first_field[T, *Ts](parser: Parser[tuple[T, *Ts]]) -> Parser[T]
Adapts a "scanner that returns a tuple" into a "plain parser returning its first
field", so it can sit inside alt / sep_by / recursive:
value = alt(first_field(scan[str]('"{}"')), first_field(scan[str]("{}")))
A Larger Composition¶
example/example.py builds a recursive node parser from these primitives: each
node competes as several patterned branches (object / array / atom), and child
nodes recurse through the recursive thunk:
# Excerpt from example/example.py
def build_node(self: Parser[Any]) -> Parser[Any]:
def parse_pair(member: str) -> tuple[str, Any]:
key, _, raw = member.partition(":")
return key.strip(), self(raw)
split_commas = split_top(",", ("[{", "}]"))
pairs = sep_by(parse_pair, split_commas)
members = sep_by(self, split_commas)
...
node = scan[
Annotated[
Any,
OneOf[
Converter(to_object, pattern=r"\{.+\}", name="object"),
Converter(to_array, pattern=r"\[.+\]", name="array"),
Converter(to_atom, pattern=r"^\w+$", name="atom"),
],
]
]("{}")
return first_field(node)
Fully runnable versions live in the repository: example/example.py,
example/moe_calc.py and example/dice_catgirl.py.
Notes¶
- Combinators distinguish only "
MatchError= no match" from "any other exception = hard failure"; a converter'sConvertErrorpasses throughaltby design. - The
recursivethunk is only called afterbuilderhas returned; calling it earlier trips anAssertionError. split_topdoes not trim whitespace: in practice member parsers absorb it (e.g.first_field(scan[int]("{}"))strips captured fields).- Combinators process whatever string they are given; whether trailing text is
acceptable is up to your parser — combinators do not enforce a full match
(
scandoes that itself).