irex / grammar.py
ntedvs's picture
Upload folder using huggingface_hub
521b329 verified
Raw History Blame Contribute Delete
19.1 kB
"""Regex pool: sample in-scope JS regexes, synthesize positive/negative strings.
Pipeline per regex: string -> parse (scope check) -> sample positives -> mutate
into negatives -> label everything with real JS semantics (QuickJS).
python src/grammar.py --n 60000
"""
import argparse
import json
import random
import string
import time
from pathlib import Path
import engine
# ---------------------------------------------------------------- char sets
U = frozenset(chr(c) for c in range(32, 127)) # printable ASCII universe
DIG = frozenset(string.digits)
WRD = frozenset(string.ascii_letters + string.digits + "_")
SPC = frozenset(" ")
ESC = {"d": DIG, "D": U - DIG, "w": WRD, "W": U - WRD, "s": SPC, "S": U - SPC}
SPECIAL = set("\\^$.|?*+()[]{}/")
# ---------------------------------------------------------------- parser
# AST: ("set", chars) | ("seq", [n]) | ("alt", [n]) | ("rep", n, lo, hi) | ("nil",)
class Parser:
def __init__(self, s):
self.s, self.i = s, 0
def peek(self):
return self.s[self.i] if self.i < len(self.s) else None
def eat(self, c=None):
ch = self.peek()
if ch is None or (c and ch != c):
raise ValueError(f"expected {c!r} at {self.i} in {self.s!r}")
self.i += 1
return ch
def parse(self):
n = self.alt()
if self.peek() is not None:
raise ValueError(f"trailing {self.s[self.i:]!r}")
return n
def alt(self):
xs = [self.seq()]
while self.peek() == "|":
self.eat()
xs.append(self.seq())
return xs[0] if len(xs) == 1 else ("alt", xs)
def seq(self):
xs = []
while self.peek() not in (None, "|", ")"):
xs.append(self.quant(self.atom()))
return ("seq", xs)
def quant(self, a):
c = self.peek()
if c in ("*", "+", "?"):
self.eat()
lo, hi = {"*": (0, None), "+": (1, None), "?": (0, 1)}[c]
elif c == "{":
self.eat()
lo = self.num()
hi = lo
if self.peek() == ",":
self.eat()
hi = self.num() if self.peek() != "}" else None
self.eat("}")
if hi is not None and hi < lo:
raise ValueError("bad range")
else:
return a
if a[0] == "nil":
raise ValueError("quantified anchor")
if self.peek() == "?": # lazy: same language for sampling
self.eat()
if self.peek() in ("*", "+", "?", "{"):
raise ValueError("stacked quantifier")
if hi is None and (nullable(a) or ambiguous(a)):
raise ValueError("ambiguous unbounded repeat (ReDoS)")
return ("rep", a, lo, hi)
def num(self):
j = self.i
while (self.peek() or "").isdigit():
self.i += 1
if j == self.i:
raise ValueError("expected number")
return int(self.s[j:self.i])
def atom(self):
c = self.eat()
if c == "(":
if self.peek() == "?":
self.eat()
self.eat(":") # only non-capturing allowed
n = self.alt()
self.eat(")")
return n
if c == "[":
return self.cls()
if c == ".":
return ("set", U)
if c in "^$":
return ("nil",)
if c == "\\":
e = self.eat()
if e in ESC:
return ("set", ESC[e])
if e in "bB":
return ("nil",)
if e in SPECIAL or e in "-":
return ("set", frozenset(e))
raise ValueError(f"out-of-scope escape \\{e}")
if c in "*+?{}|)]":
raise ValueError(f"unexpected {c!r}")
return ("set", frozenset(c))
def cls(self):
neg = self.peek() == "^"
if neg:
self.eat()
out, first = set(), True
while self.peek() != "]" or first:
first = False
lo = self.cls_char()
if isinstance(lo, frozenset):
out |= lo
continue
if self.peek() == "-" and self.s[self.i + 1:self.i + 2] not in ("]", ""):
self.eat()
hi = self.cls_char()
if isinstance(hi, frozenset) or ord(hi) < ord(lo):
raise ValueError("bad class range")
out |= {chr(x) for x in range(ord(lo), ord(hi) + 1)}
else:
out.add(lo)
self.eat("]")
out &= U
return ("set", frozenset(U - out if neg else out))
def cls_char(self):
c = self.eat()
if c != "\\":
return c
e = self.eat()
if e in ESC:
return ESC[e]
if e in SPECIAL or e in "-]^":
return e
raise ValueError(f"out-of-scope class escape \\{e}")
def nullable(n) -> bool:
k = n[0]
if k == "set":
return False
if k == "seq":
return all(map(nullable, n[1]))
if k == "alt":
return any(map(nullable, n[1]))
if k == "rep":
return n[2] == 0 or nullable(n[1])
return True
def _chars(n):
"""First-level char set of a quantified atom, else None."""
while n[0] == "seq" and len(n[1]) == 1:
n = n[1][0]
if n[0] == "rep":
return _chars(n[1])
return n[1] if n[0] == "set" else None
def ambiguous(n) -> bool:
r"""Body shapes that backtrack exponentially under an outer + or *:
(a+)+, (\s*\s+)+, (\w+\d+)* ... i.e. a nested unbounded repeat, or two
adjacent quantified atoms with overlapping chars."""
while n[0] == "seq" and len(n[1]) == 1:
n = n[1][0]
if n[0] == "rep" and (n[3] is None or n[3] > 1):
return True
if n[0] == "alt":
return any(map(ambiguous, n[1]))
if n[0] != "seq":
return False
xs = n[1]
for x, y in zip(xs, xs[1:]):
if x[0] == "rep" and y[0] == "rep":
cx, cy = _chars(x), _chars(y)
if cx is None or cy is None or cx & cy:
return True
return False
def parse(pattern: str):
return Parser(pattern).parse()
def in_scope(pattern: str, flags: str = "") -> bool:
try:
parse(pattern)
except (ValueError, IndexError):
return False
return set(flags) <= {"i"} and engine.compiles(pattern, flags)
# ---------------------------------------------------------------- sampler
_SORTED: dict = {}
def _sorted(s):
t = _SORTED.get(s)
if t is None:
t = _SORTED[s] = tuple(sorted(s))
return t
def sample(n, rng: random.Random, ci=False) -> str:
k = n[0]
if k == "set":
if not n[1]:
raise ValueError("empty set")
c = rng.choice(_sorted(n[1]))
return c.swapcase() if ci and rng.random() < 0.3 else c
if k == "seq":
return "".join(sample(x, rng, ci) for x in n[1])
if k == "alt":
return sample(rng.choice(n[1]), rng, ci)
if k == "rep":
hi = n[3] if n[3] is not None else n[2] + rng.choice([0, 1, 2, 3, 5])
return "".join(sample(n[1], rng, ci) for _ in range(rng.randint(n[2], min(hi, n[2] + 6))))
return ""
NOISE = string.ascii_letters + string.digits + " .-_@/:#$,"
def mutate(s: str, rng: random.Random) -> str:
op = rng.randrange(7)
i = rng.randrange(len(s) + 1)
c = rng.choice(NOISE)
if op == 0 and s:
return s[:i] + s[i + 1:] # delete
if op == 1:
return s[:i] + c + s[i:] # insert
if op == 2 and s:
i = min(i, len(s) - 1)
return s[:i] + c + s[i + 1:] # substitute
if op == 3:
return c + s # prefix (breaks ^)
if op == 4:
return s + c # suffix (breaks $)
if op == 5 and len(s) > 1:
return s[: rng.randrange(1, len(s))] # truncate
return s.swapcase()
def examples(pattern, flags, rng, k=5):
"""-> (pos, neg) labeled by JS, or None if degenerate or slow (ReDoS)."""
t = time.perf_counter()
try:
ex = _examples(pattern, flags, rng, k)
except Exception: # QuickJS time limit, empty set, ...
return None
return ex if time.perf_counter() - t < 0.05 else None
def _examples(pattern, flags, rng, k):
tree = parse(pattern)
ci = "i" in flags
cand = list(dict.fromkeys(sample(tree, rng, ci) for _ in range(24)))
cand = [s for s in cand if s and len(s) <= 60]
if len(cand) < 3:
return None
ok = engine.test(pattern, flags, cand)
pos = [s for s, m in zip(cand, ok) if m]
if len(pos) < 3:
return None
muts = [m for m in dict.fromkeys(mutate(rng.choice(pos), rng) for _ in range(40)) if m not in pos]
muts += ["".join(rng.choices(NOISE, k=rng.randint(1, 12))) for _ in range(6)]
ok = engine.test(pattern, flags, muts)
neg = [s for s, m in zip(muts, ok) if not m and s.strip()]
if len(neg) < 3:
return None
rng.shuffle(pos), rng.shuffle(neg)
return pos[:k], neg[:k]
# ---------------------------------------------------------------- generator
WORDS = """cat dog bird fish red green blue black white gray error warn info debug
fatal http https ftp www com org net io dev app api user admin root guest test
id key token name file data log img src lib bin tmp home yes no true false null
on off get post put delete open close start stop begin end jan feb mar apr may
jun jul aug sep oct nov dec mon tue wed thu fri sat sun am pm usd eur gbp jpg
png gif pdf txt csv json xml html css js py md zip tar gz mp3 mp4 wav todo fixme
note hello world foo bar baz qux the and or not color colour gray grey cancel
ok fail pass high low new old big small north south east west""".split()
CLASSES = ["[a-z]", "[A-Z]", "[a-zA-Z]", "[0-9]", "[a-z0-9]", "[A-Za-z0-9_]",
"[aeiou]", "[^aeiou]", "[a-f0-9]", "[A-F0-9]", "[0-7]", "[1-9]",
"[01]", "[^0-9]", "[^\\s]", "[-_.]", "[.,;:!?]", "[+-]", "[a-zA-Z ]",
"[^a-z]", "[A-Z0-9]", "[a-zA-Z0-9.-]", "[^,]", "[^\"]", "[xyz]"]
ESCAPES = ["\\d", "\\w", "\\s", "\\D", "\\W", "\\S", "."]
SEPS = ["-", "\\.", "_", ":", "\\/", " ", "@", ",", "\\s", "\\s*", "\\s+", "#", "="]
def lit(s):
return "".join("\\" + c if c in SPECIAL else c for c in s)
def rand_class(rng):
if rng.random() < 0.2:
cs = "".join(sorted(rng.sample(string.ascii_lowercase, rng.randint(2, 5))))
return f"[{'^' if rng.random() < 0.2 else ''}{cs}]"
return rng.choice(CLASSES)
def quant(rng, allow_none=True):
r = rng.random()
lo = rng.randint(1, 5)
q = ("" if allow_none else "+") if r < 0.25 else \
"?" if r < 0.35 else "*" if r < 0.47 else "+" if r < 0.65 else \
f"{{{lo}}}" if r < 0.8 else f"{{{lo},}}" if r < 0.86 else f"{{{lo},{lo + rng.randint(1, 6)}}}"
return q + ("?" if q and rng.random() < 0.04 else "")
def atom(rng):
r = rng.random()
if r < 0.5:
return rand_class(rng)
if r < 0.85:
return rng.choice(ESCAPES)
return lit(rng.choice(string.ascii_lowercase + "-_.@#$%!"))
def group(rng, body):
return f"({'?:' if rng.random() < 0.4 else ''}{body})"
def chunk(rng, depth=0):
r = rng.random()
if r < 0.18:
return lit(rng.choice(WORDS))
if r < 0.55:
return atom(rng) + quant(rng)
if r < 0.72:
alts = rng.sample(WORDS, rng.randint(2, 4))
return group(rng, "|".join(map(lit, alts))) + ("?" if rng.random() < 0.15 else "")
if r < 0.85 and depth < 1:
body = rng.choice(SEPS) + chunk(rng, depth + 1)
return group(rng, body) + rng.choice(["?", "*", "+", "?", f"{{{rng.randint(2, 4)}}}"])
return rng.choice(SEPS)
def anchor(rng, body):
r = rng.random()
if r < 0.4:
return f"^{body}$"
if r < 0.55:
return f"^{body}"
if r < 0.65:
return f"{body}$"
if r < 0.8:
return f"\\b{body}\\b"
return body
def random_regex(rng):
body = "".join(chunk(rng) for _ in range(rng.choice([1, 1, 2, 2, 2, 3, 3, 4])))
p = anchor(rng, body)
letters = any(c.isalpha() for c in p.replace("\\", " \\"))
return p, ("i" if letters and rng.random() < 0.15 else "")
def C(*xs):
return lambda rng: rng.choice(xs)
def opt(rng, s, p=0.5):
return s if rng.random() < p else ""
# realistic shapes; each call randomizes details
TEMPLATES = [
lambda r: f"^\\d{{{r.choice([3, 4, 5, 6, 8])}}}$",
lambda r: "^\\d{5}" + opt(r, "(?:-\\d{4})?") + "$",
lambda r: "^" + opt(r, "\\(?") + "\\d{3}" + opt(r, "\\)?") + C("-", "[-.\\s]?", "\\s?", "[-. ]")(r) + "\\d{3}" + C("-", "[-.]?", "[-. ]")(r) + "\\d{4}$",
lambda r: (lambda s: f"^\\d{{4}}{s}\\d{{2}}{s}\\d{{2}}$")(C("-", "\\/", "\\.")(r)),
lambda r: (lambda s: f"^\\d{{1,2}}{s}\\d{{1,2}}{s}\\d{{{r.choice(['2', '4', '2,4'])}}}$")(C("\\/", "-", "\\.")(r)),
lambda r: "^" + C("\\d{2}", "[01]\\d", "\\d{1,2}", "(?:[01]\\d|2[0-3])")(r) + ":[0-5]\\d" + opt(r, "(?::[0-5]\\d)?") + opt(r, "\\s?(?:am|pm)", 0.3) + "$",
lambda r: "^#" + C("[0-9a-fA-F]{6}", "[0-9a-f]{6}", "(?:[0-9a-fA-F]{3}){1,2}", "[A-F0-9]{6}")(r) + "$",
lambda r: "^" + C("\\d{1,3}(?:\\.\\d{1,3}){3}", "(?:\\d{1,3}\\.){3}\\d{1,3}")(r) + "$",
lambda r: "^" + C("[\\w.+-]+", "[a-z0-9._%+-]+", "\\w+", "[a-zA-Z0-9._-]+")(r) + "@" + C("[\\w-]+", "[a-z0-9-]+", "\\w+")(r) + C("\\.[a-z]{2,}", "\\.[a-zA-Z]{2,6}", "(?:\\.\\w+)+", "\\.(?:com|org|net)")(r) + "$",
lambda r: "^" + C("https?", "https", "(?:https?|ftp)")(r) + ":\\/\\/" + opt(r, "(?:www\\.)?") + C("[\\w-]+(?:\\.[\\w-]+)+", "[^\\s\\/]+", "[a-z0-9.-]+\\.[a-z]{2,}")(r) + opt(r, "(?:\\/\\S*)?") + "$",
lambda r: "\\." + group(r, "|".join(r.sample(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"], r.randint(2, 4)))) + "$",
lambda r: "\\." + group(r, "|".join(r.sample(["pdf", "docx?", "txt", "md", "csv", "xlsx?", "json", "ya?ml"], r.randint(2, 4)))) + "$",
lambda r: C("#\\w+", "#[a-zA-Z]\\w*", "\\B#\\w+", "#[a-z0-9_]+")(r),
lambda r: C("@\\w+", "@[a-zA-Z0-9_]{1,15}", "\\B@\\w+")(r),
lambda r: "^\\$" + C("\\d+", "\\d{1,3}(?:,\\d{3})*")(r) + opt(r, "(?:\\.\\d{2})?", 0.7) + "$",
lambda r: "^[a-z" + opt(r, "A-Z") + "][a-z" + opt(r, "A-Z") + "0-9_" + opt(r, "-") + f"]{{{r.randint(2, 4)},{r.randint(12, 20)}}}$",
lambda r: "^" + opt(r, "v?") + "\\d+\\.\\d+" + C("\\.\\d+", "(?:\\.\\d+)?")(r) + opt(r, "(?:-[a-z0-9.]+)?", 0.3) + "$",
lambda r: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
lambda r: "^" + C("\\d{4}(?:[- ]?\\d{4}){3}", "(?:\\d{4}[- ]?){3}\\d{4}", "\\d{16}")(r) + "$",
lambda r: "^" + opt(r, "-?") + C("\\d+", "\\d{1,3}", "100|\\d{1,2}", "\\d+(?:\\.\\d+)?")(r) + "%$",
lambda r: "^" + opt(r, "[+-]?") + C("\\d+", "\\d*\\.\\d+", "\\d+(?:\\.\\d+)?", "\\d+(?:\\.\\d*)?")(r) + opt(r, "(?:[eE][+-]?\\d+)?", 0.25) + "$",
lambda r: "^0x[0-9a-fA-F]+$" if r.random() < 0.5 else "^(?:0x)?[0-9a-f]+$",
lambda r: "^[01]+$" if r.random() < 0.5 else f"^[01]{{{r.choice([4, 8, 16])}}}$",
lambda r: f"\\b{lit(r.choice(WORDS))}\\w*" if r.random() < 0.5 else f"\\w*{lit(r.choice(['ing', 'ed', 'ly', 'tion', 's', 'er']))}\\b",
lambda r: f"^{lit(r.choice(WORDS))}" + C("\\b", ".*", "\\s", ":")(r),
lambda r: f"{lit(r.choice(WORDS))}$",
lambda r: "\\b" + group(r, "|".join(map(lit, r.sample(WORDS, r.randint(2, 5))))) + "\\b",
lambda r: "^" + group(r, "|".join(map(lit, r.sample(WORDS, r.randint(2, 5))))) + "$",
lambda r: "^" + C("[A-Z]", "[A-Z][a-z]+", "[a-z]")(r) + C("[a-z]*", "\\w*", "[a-z]+(?: [a-z]+)*")(r) + opt(r, "[.!?]") + "$",
lambda r: "^\\s*$" if r.random() < 0.3 else C("^\\s+", "\\s+$", "\\s{2,}", "^\\s+|\\s+$")(r),
lambda r: "^" + C("[A-Z]{2}", "[A-Z]{3}", "[A-Z]{2,3}")(r) + C("-", "", "\\s?", "_")(r) + f"\\d{{{r.randint(2, 6)}}}$",
lambda r: "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$",
lambda r: "<" + C("[a-z]+", "\\/?[a-z]+", "[a-z][a-z0-9]*")(r) + C(">", "[^>]*>", "\\s*\\/?>")(r),
lambda r: "\"" + C("[^\"]*", "[^\"]+", ".*?")(r) + "\"",
lambda r: "\\(" + C("[^)]*", "\\d+", "\\w+")(r) + "\\)",
lambda r: "\\[" + C("[^\\]]*", "\\d+", "\\w+")(r) + "\\]",
lambda r: "^" + group(r, "|".join(r.sample(["ERROR", "WARN", "INFO", "DEBUG", "FATAL", "TRACE"], r.randint(2, 4)))) + C(":", "\\s", "\\b", ":\\s.*")(r),
lambda r: "^\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}" + opt(r, ":\\d{2}") + opt(r, "(?:Z|[+-]\\d{2}:\\d{2})?", 0.3) + "$",
lambda r: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
lambda r: "^" + C("\\d+", "[1-9]\\d*", "[1-9]\\d{0,2}", "0|[1-9]\\d*")(r) + "$",
lambda r: "^(?:" + C("\\w+", "[a-z]+", "\\d+")(r) + C(",", ";", "\\|", ",\\s*")(r) + ")*" + C("\\w+", "[a-z]+", "\\d+")(r) + "$",
lambda r: "^" + C("[A-Za-z]{2,}", "[A-Z][a-z]+")(r) + " " + C("[A-Za-z]{2,}", "[A-Z][a-z]+")(r) + "$",
lambda r: C("colou?r", "gr[ae]y", "(?:cancell?ed)", "favou?rite", "analy[sz]e")(r),
lambda r: C("\\d+(?:px|em|rem|%)", "-?\\d+(?:\\.\\d+)?(?:px|em|rem)", "\\d+(?:ms|s)")(r),
lambda r: "^[a-z]+(?:-[a-z]+)*$" if r.random() < 0.5 else C("^[a-z]+(?:_[a-z]+)*$", "^[a-z]+(?:[A-Z][a-z]+)*$", "^[A-Z][a-z]+(?:[A-Z][a-z]+)*$")(r),
lambda r: "^\\/" + C("[\\w-]+(?:\\/[\\w-]+)*", "(?:[\\w.-]+\\/)*[\\w.-]+", "[a-z]+\\/\\d+")(r) + opt(r, "\\/?") + "$",
lambda r: "^" + C("[A-Z]{3}", "[A-Z]{2}")(r) + "\\s?\\d+(?:\\.\\d{2})?$",
lambda r: "\\b\\d{1,2}(?:st|nd|rd|th)\\b",
lambda r: "^(?:" + "|".join(r.sample(["mon", "tue", "wed", "thu", "fri", "sat", "sun"], r.randint(3, 7))) + ")$",
lambda r: "^(?:" + "|".join(r.sample(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], r.randint(3, 6))) + ")" + opt(r, "[a-z]*") + "$",
lambda r: "^[a-zA-Z0-9]{" + str(r.choice([6, 8, 10, 12])) + ",}$",
lambda r: "^[^\\s]+$" if r.random() < 0.5 else "^\\S+$",
lambda r: "^.{" + str(r.randint(1, 5)) + "," + str(r.randint(8, 40)) + "}$",
]
def flags_for(rng, p):
return "i" if any(c.isalpha() for c in p if c not in "dwsDWSbB") and rng.random() < 0.15 else ""
def regex(rng):
if rng.random() < 0.5:
p = rng.choice(TEMPLATES)(rng)
return p, flags_for(rng, p)
return random_regex(rng)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=60000)
ap.add_argument("--out", default=Path(__file__).parents[1] / "data/regex.jsonl")
ap.add_argument("--seed", type=int, default=0)
a = ap.parse_args()
rng, seen, rows, tries = random.Random(a.seed), set(), 0, 0
with open(a.out, "w") as f:
while rows < a.n and tries < a.n * 20:
tries += 1
p, fl = regex(rng)
key = (p, fl)
if key in seen or not (2 <= len(p) <= 72) or not in_scope(p, fl):
continue
seen.add(key)
ex = examples(p, fl, rng)
if not ex:
continue
f.write(json.dumps({"id": rows, "regex": engine.literal(p, fl), "pattern": p,
"flags": fl, "pos": ex[0], "neg": ex[1]}) + "\n")
rows += 1
print(f"{rows} regexes from {tries} tries -> {a.out}")
if __name__ == "__main__":
main()