From 7c4f9bb974a51982e003299412392331a39f7dd9 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Wed, 1 Jul 2026 12:48:12 -0400 Subject: [PATCH] Compile capturing regexes with the backtracking matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit irregex builds a POSIX leftmost-longest DFA for a pattern when it can, and jolt used it for everything. For a pattern with an alternation whose branches have capturing groups, that DFA leaks a non-participating branch's group: e.g. #"(?:([0-9])|([0-9])r([0-9]+))" on "2r11" left group 1 = "2" instead of nil, so tools.reader (rewrite-clj's dep) misread 2r1100 as 2 and 16rFF as 16. java.util.regex is itself a leftmost-first backtracking engine, so compile a capturing pattern with irregex's backtracking matcher ('backtrack): its submatch semantics match the JVM and it clears a losing branch's group. Non-capturing patterns keep the DFA — with no groups to read, its whole-match result is all a caller sees, and it avoids backtracking's worst case. The submatch count comes from a first cheap compile; a capturing pattern recompiles once and caches. This clears the last rewrite-clj parser-test failure (now 772/0/0). Corpus rows for the alternation-group case and the radix read. make test green. --- host/chez/regex.ss | 17 +++++++++++++++-- test/chez/corpus.edn | 2 ++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/host/chez/regex.ss b/host/chez/regex.ss index 88ad864..d320284 100644 --- a/host/chez/regex.ss +++ b/host/chez/regex.ss @@ -159,10 +159,23 @@ ;; A jolt regex value: the source string (for printing / str) + the compiled ;; irregex. regex? recognizes it; the printer renders #"source". (define-record-type regex-t (fields source irx) (nongenerative jolt-regex-v1)) +;; A capturing pattern is compiled with irregex's BACKTRACKING matcher ('backtrack), +;; not its DFA. java.util.regex is itself a leftmost-first backtracking engine, so +;; this matches the JVM's submatch semantics; irregex's DFA is POSIX leftmost-longest +;; and, worse, leaks a non-participating alternation group's capture (e.g. +;; #"(?:([0-9])|([0-9])r([0-9]+))" on "2r11" left group 1 = "2"), which broke +;; tools.reader's number reader. Non-capturing patterns keep the fast DFA — with no +;; groups to read, its whole-match result is all a caller sees. The count comes from +;; a first cheap compile; a capturing pattern is recompiled once (patterns compile +;; once and cache in the regex-t). (define (jolt-regex source) (let-values (((opts pat) (regex-parse-flags source))) - (make-regex-t source - (apply irregex (translate-prop-classes (escape-class-shorthand-dash pat)) opts)))) + (let* ((p (translate-prop-classes (escape-class-shorthand-dash pat))) + (irx (apply irregex p opts))) + (make-regex-t source + (if (> (irregex-num-submatches irx) 0) + (apply irregex p 'backtrack opts) + irx))))) (define (jolt-regex? x) (regex-t? x)) (define (jolt-re-pattern x) (if (regex-t? x) x (jolt-regex x))) diff --git a/test/chez/corpus.edn b/test/chez/corpus.edn index 09efb13..354b45e 100644 --- a/test/chez/corpus.edn +++ b/test/chez/corpus.edn @@ -1,5 +1,7 @@ [ {:suite "host-interop / reify ILookup" :label "(get reify k) / (:k reify) / (get reify k d) route to valAt" :expected "[1 1 :dflt]" :actual "(let [r (reify clojure.lang.ILookup (valAt [_ k] (get {:a 1} k)) (valAt [_ k d] (get {:a 1} k d)))] [(:a r) (get r :a) (get r :z :dflt)])"} + {:suite "regex / alternation submatch" :label "a non-participating alternation group is nil, not a stale capture" :expected "[\"2r11\" nil \"2\" \"11\"]" :actual "(re-matches #\"(?:([0-9])|([0-9])r([0-9]+))\" \"2r11\")"} + {:suite "regex / radix read" :label "tools.reader-style radix parse: winning branch's groups, not the losing one's" :expected "[12 255]" :actual "[(read-string \"2r1100\") (read-string \"16rFF\")]"} {:suite "numbers / comparison error class" :label "nil operand is NPE, non-number is CCE" :expected "[\"java.lang.NullPointerException\" \"java.lang.ClassCastException\"]" :actual "[(try (> nil 5) (catch Throwable e (.getName (class e)))) (try (> :k 5) (catch Throwable e (.getName (class e))))]"} {:suite "reader / quoted #inst constructs a value" :label "a quoted #inst literal is the constructed Date, not a tagged form" :expected "\"(f #inst \\\"1939-01-01T00:00:00.000-00:00\\\")\"" :actual "(pr-str (quote (f #inst \"1939\")))"} {:suite "fn / kwargs trailing map" :label "(f :k v {map}) and (f {map}) both bind & {:as m}" :expected "[{:x 1 :y 2} {:x 1 :y 2}]" :actual "[((fn [a & {:as m}] m) 1 :x 1 {:y 2}) ((fn [a & {:as m}] m) 1 {:x 1 :y 2})]"}