perf(std regex): native fast-path re-fold-positions + substring-free re-fold

ober

a942c986357a171ade082d931f176585fe2cbbf5

diff --git a/lib/std/regex.ss b/lib/std/regex.ss
index 07b895b..a23f005 100644
--- a/lib/std/regex.ss
+++ b/lib/std/regex.ss
@@ -39,7 +39,7 @@
     ;; Replacement
     re-replace re-replace-all
     ;; Splitting and folding
-    re-split re-fold
+    re-split re-fold re-fold-positions
     ;; Match object accessors
     re-match-full re-match-group re-match-groups
     re-match-start re-match-end re-match-named
@@ -95,6 +95,11 @@
     (if native-available?
       (foreign-procedure "jerboa_regex_free" (unsigned-64) int)
       (lambda args (error 'c-native-free "native backend not available"))))
+  (def c-native-find-at
+    (if native-available?
+      (foreign-procedure "jerboa_regex_find_at"
+                         (unsigned-64 u8* size_t size_t u8* u8*) int)
+      (lambda args (error 'c-native-find-at "native backend not available"))))
 
   ;; ========== Records ==========
 
@@ -341,8 +346,83 @@
   (def (re-split pat str)
     (pregexp-split (re-object-pat-string (re pat)) str))
 
+  ;; ASCII-only check: returns #t iff every char in str has code <= 0x7F.
+  ;; Used by re-fold-positions to decide whether the native (Rust regex,
+  ;; UTF-8) backend can be used safely.  For non-ASCII strings the byte
+  ;; offsets returned by native would not equal char offsets (because
+  ;; latin-1 bytes 0x80..0xFF become 2-byte UTF-8 sequences) and the
+  ;; caller's coordinate system would break.  This scan is O(N) but
+  ;; trivial per-char; for text files (the common multi-match case) it
+  ;; runs once and almost always returns #t.
+  (def (ascii-only-string? str)
+    (let ([n (string-length str)])
+      (let loop ([i 0])
+        (cond
+          [(fx= i n) #t]
+          [(fx<= (char->integer (string-ref str i)) #x7f)
+            (loop (fx+ i 1))]
+          [else #f]))))
+
+  ;; re-fold-positions: fold kons over (start, end) byte-offset pairs of
+  ;; all matches in str, left-to-right.  kons receives (index start end
+  ;; subject accumulator) and returns the new accumulator.
+  ;;
+  ;; When the native (Rust regex) backend is available AND the subject is
+  ;; pure ASCII, the iteration runs in O(N) total time using
+  ;; jerboa_regex_find_at — no backtracking, no per-iteration substring
+  ;; or capture-group allocation.  Used by high-throughput callers
+  ;; (e.g. multi-rule scanners) that only need positions.  Falls back to
+  ;; pregexp when no native handle or when the subject has non-ASCII
+  ;; chars (where UTF-8 byte offsets would differ from char offsets).
+  (def (re-fold-positions pat kons knil str)
+    (let* ([r       (re pat)]
+           [handle  (re-object-native-handle r)]
+           [pat-str (re-object-pat-string r)]
+           [len     (string-length str)])
+      (cond
+        [(and native-available? handle (ascii-only-string? str))
+          ;; Native fast path: bytes-based linear scan with find_at.
+          ;; ASCII-confined: byte offsets == char offsets, so positions
+          ;; round-trip cleanly back to the caller's coordinate system.
+          (let* ([bv  (string->utf8 str)]
+                 [bl  (bytevector-length bv)]
+                 [sbuf (make-bytevector 8)]
+                 [ebuf (make-bytevector 8)])
+            (let loop ([pos 0] [i 0] [acc knil])
+              (cond
+                [(> pos bl) acc]
+                [else
+                  (let ([rc (c-native-find-at handle bv bl pos sbuf ebuf)])
+                    (cond
+                      [(= rc 1)
+                        (let* ([ms (bytevector-u64-native-ref sbuf 0)]
+                               [me (bytevector-u64-native-ref ebuf 0)]
+                               [next (if (> me ms) me (+ ms 1))])
+                          (loop next (+ i 1) (kons i ms me str acc)))]
+                      [else acc]))])))]
+        [else
+          ;; Fallback: pregexp positions, start/end form (no substring).
+          (let loop ([pos 0] [i 0] [acc knil])
+            (cond
+              [(> pos len) acc]
+              [else
+                (let ([rp (pregexp-match-positions pat-str str pos len)])
+                  (cond
+                    [(not rp) acc]
+                    [else
+                      (let* ([ms (caar rp)]
+                             [me (cdar rp)]
+                             [next (if (> me ms) me (+ ms 1))])
+                        (loop next (+ i 1) (kons i ms me str acc)))]))]))])))
+
   ;; re-fold: fold kons over all matches left-to-right.
   ;; kons receives (match-index re-match-object subject accumulator).
+  ;;
+  ;; Uses pregexp-match-positions's 4-arg form to search a (start, end)
+  ;; window of the subject string directly — avoids per-iteration
+  ;; substring allocation that turned re-fold into O(K * N) for a string
+  ;; with K matches in N chars.  Returned positions are already in
+  ;; subject-absolute coordinates.
   (def (re-fold pat kons knil str)
     (let* ([r       (re pat)]
            [pat-str (re-object-pat-string r)]
@@ -351,15 +431,10 @@
       (let loop ([pos 0] [i 0] [acc knil])
         (if (> pos len)
           acc
-          (let* ([subject  (if (= pos 0) str (substring str pos len))]
-                 [raw-pos  (pregexp-match-positions pat-str subject)])
+          (let ([raw-pos (pregexp-match-positions pat-str str pos len)])
             (if (not raw-pos)
               acc
-              (let* ([adj-pos  (map (lambda (p)
-                                      (and p (cons (+ pos (car p))
-                                                   (+ pos (cdr p)))))
-                                    raw-pos)]
-                     [m        (build-match-object str adj-pos named)]
+              (let* ([m        (build-match-object str raw-pos named)]
                      [mstart   (re-match-object-start m)]
                      [mend     (re-match-object-end m)]
                      [next     (max (+ mstart 1) mend)])