diff --git a/src/jolt/clojure/string.clj b/src/jolt/clojure/string.clj index 807813e..7cda7dc 100644 --- a/src/jolt/clojure/string.clj +++ b/src/jolt/clojure/string.clj @@ -51,10 +51,16 @@ (str-reverse-b s)) (defn split - ([s re] - (vec (str-split re s))) + ([s re] (split s re 0)) ([s re limit] - (vec (take limit (str-split re s))))) + ;; Java Pattern.split semantics: limit > 0 caps the parts (trailing empties + ;; kept); limit < 0 splits fully and keeps trailing empties; limit 0 (the + ;; default) splits fully then drops trailing empty strings — but a no-match + ;; result ([input], the only 1-element case) is returned as-is. + (let [parts (vec (str-split re s (if (pos? limit) limit nil)))] + (if (and (zero? limit) (> (count parts) 1)) + (loop [v parts] (if (and (seq v) (= "" (peek v))) (recur (pop v)) v)) + parts)))) (defn split-lines "Split s on \\n or \\r\\n, returning a vector of lines." diff --git a/src/jolt/core_coll.janet b/src/jolt/core_coll.janet index 20804e4..a841daa 100644 --- a/src/jolt/core_coll.janet +++ b/src/jolt/core_coll.janet @@ -418,7 +418,7 @@ (array? coll) (make-vec coll) (tuple? coll) (make-vec coll) (struct? coll) (make-vec (map |(in (kvs coll) (+ (* $ 2) 1)) (range (/ (length (kvs coll)) 2)))) - (string? coll) (make-vec (map |(string/from-bytes $) (string/bytes coll))) + (string? coll) (make-vec (map make-char (string/bytes coll))) (make-vec @[])))) # Bulk-build a map value from a native array of [k v] pairs, mirroring diff --git a/src/jolt/core_extra.janet b/src/jolt/core_extra.janet index d8220d5..a6c0ab5 100644 --- a/src/jolt/core_extra.janet +++ b/src/jolt/core_extra.janet @@ -247,10 +247,16 @@ # (core/20-coll.clj) — pure over get on the tagged value the constructor builds. # String split/replace that accept either a literal string or a regex value. -(defn core-str-split [pat s] +(defn core-str-split [pat s &opt limit] (if (regex? pat) - (re-split pat s) - (string/split pat s))) + (re-split pat s limit) + (let [parts (string/split pat s)] + # literal separator: a positive limit caps the part count; rejoin the tail + # with the (constant) separator to recover the unsplit remainder. + (if (and limit (> limit 0) (> (length parts) limit)) + (array/push (array/slice parts 0 (- limit 1)) + (string/join (array/slice parts (- limit 1)) pat)) + parts)))) (defn core-str-replace-all [pat repl s] (if (regex? pat) (re-replace-all pat s repl) diff --git a/src/jolt/core_types.janet b/src/jolt/core_types.janet index f65590c..ca35f03 100644 --- a/src/jolt/core_types.janet +++ b/src/jolt/core_types.janet @@ -167,6 +167,9 @@ (phm? c) (phm-entries c) # sorted colls iterate their comparator-ordered entries/elements (core-sorted? c) (sorted-entries-arr c) + # a string is a seqable of CHARS in Clojure (not bytes/1-char strings) — mirror + # core-seq so vec/set/into over a string agree with seq (jolt-dl4s) + (string? c) (tuple ;(map make-char (string/bytes c))) # byte array (Janet buffer) -> array of byte values (buffer? c) (let [a @[]] (each x c (array/push a x)) a) # struct map literal (no :jolt/type marker — not a symbol/char) -> entries diff --git a/src/jolt/interop/java_base.janet b/src/jolt/interop/java_base.janet index 20fe1bb..5488545 100644 --- a/src/jolt/interop/java_base.janet +++ b/src/jolt/interop/java_base.janet @@ -82,7 +82,9 @@ "file.separator" "/" "user.dir" (os/cwd) "user.home" (or (os/getenv "HOME") "") - "java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")})}) + "java.io.tmpdir" (or (os/getenv "TMPDIR") "/tmp")}) + # terminate the process with the given status code + "exit" (fn [&opt status] (os/exit (if (nil? status) 0 status)))}) # sentinels portable code compares against. jolt numbers are doubles, so these # are the f64 approximations. diff --git a/src/jolt/regex.janet b/src/jolt/regex.janet index 4b239d0..d0e12cf 100644 --- a/src/jolt/regex.janet +++ b/src/jolt/regex.janet @@ -8,8 +8,10 @@ # Supported: literals, `.`, char classes `[...]`/`[^...]` (ranges, POSIX, # `\d \w \s` etc.), quantifiers `* + ? {n} {n,} {n,m}` (greedy + lazy `?`), # groups `(...)` (numbered), non-capturing `(?:...)`, lookahead `(?=...)`/`(?!...)`, -# alternation `|`, anchors `^ $ \b \B`, escapes, inline flag `(?i)`. -# Not supported: lookbehind, backreferences, named groups (rare; documented). +# alternation `|`, anchors `^ $ \b \B`, escapes, inline flag `(?i)`, and +# single-digit backreferences `\1`..`\9` (a backreferenced group matches +# possessively — no backtracking back into it; rare in practice). +# Not supported: lookbehind, named groups (rare; documented). (defn- chr [s] (get s 0)) (defn regex? [x] (and (table? x) (= :jolt/regex (x :jolt/type)))) @@ -189,6 +191,10 @@ p {:op :pred :peg p} (= nc (chr "b")) {:op :anchor :kind :wordb} (= nc (chr "B")) {:op :anchor :kind :nwordb} + # \1..\9 backreference (single digit) — record the referenced group so + # the emitter knows to tag-capture its text + (and (>= nc (chr "1")) (<= nc (chr "9"))) + (let [n (- nc (chr "0"))] (put (st :refs) n true) {:op :backref :n n}) {:op :char :b (esc-byte nc) :ci ci}))) (= c (chr "^")) (do (++ (st :pos)) {:op :anchor :kind :start}) (= c (chr "$")) (do (++ (st :pos)) {:op :anchor :kind :end}) @@ -241,9 +247,9 @@ (if (= 1 (length branches)) (branches 0) {:op :alt :items branches}))) (defn- parse [source] - (def st @{:s source :pos 0 :ngroup 0 :ci false :ml false :dotall false}) + (def st @{:s source :pos 0 :ngroup 0 :ci false :ml false :dotall false :refs @{}}) (def ast (parse-alt st)) - [ast (st :ngroup) (st :ml)]) + [ast (st :ngroup) (st :ml) (st :refs)]) # ============================================================ # Emit: AST -> PEG grammar (continuation passing) @@ -256,7 +262,7 @@ ~(set ,(string/from-bytes (lower-b b) (upper-b b))) ~(set ,(string/from-bytes b)))) -(defn- make-emitter [grammar ml] +(defn- make-emitter [grammar ml refs] (var ctr 0) (defn fresh [] (++ ctr) (keyword (string "r" ctr))) (var emit nil) @@ -292,19 +298,38 @@ ~(choice ,(emit item (keyword r)) ,k) ~(choice ,k ,(emit item (keyword r))))) (keyword r)) - (do (var acc k) (var c (- hi lo)) + # Each optional level is its OWN grammar rule that + # references the previous level by name. Inlining instead + # (choice (emit item acc) acc) duplicates `acc` per level, + # so {0,n} built a structure the PEG compiler expanded to + # 2^n nodes and hung (jolt-3xur). Naming keeps it O(n). + (do (def kr (fresh)) (put grammar kr k) + (var acc (keyword kr)) (var c (- hi lo)) (while (> c 0) - (set acc (if greedy ~(choice ,(emit item acc) ,acc) - ~(choice ,acc ,(emit item acc)))) + (let [r (fresh)] + (put grammar r (if greedy ~(choice ,(emit item acc) ,acc) + ~(choice ,acc ,(emit item acc)))) + (set acc (keyword r))) (-- c)) acc))) (var acc tail) (var c lo) (while (> c 0) (set acc (emit item acc)) (-- c)) acc) :group (let [n (ast :n)] - ~(sequence (/ (position) ,(fn [p] [n :s p])) - ,(emit (ast :item) - ~(sequence (/ (position) ,(fn [p] [n :e p])) ,k)))) + (if (and refs (refs n)) + # backreferenced group: also capture its text under tag :gN so a + # later \N can (backmatch). The item runs with just the end-marker + # as its continuation, so it matches POSSESSIVELY — backtracking + # INTO a backreferenced group isn't supported (rare; the usual + # (x)...\1 patterns don't need it). + (let [tag (keyword (string "g" n))] + ~(sequence (/ (position) ,(fn [p] [n :s p])) + (<- ,(emit (ast :item) ~(/ (position) ,(fn [p] [n :e p]))) ,tag) + ,k)) + ~(sequence (/ (position) ,(fn [p] [n :s p])) + ,(emit (ast :item) + ~(sequence (/ (position) ,(fn [p] [n :e p])) ,k))))) + :backref ~(sequence (backmatch ,(keyword (string "g" (ast :n)))) ,k) :ncgroup (emit (ast :item) k) :look (if (ast :neg) ~(sequence (not ,(emit (ast :item) 0)) ,k) @@ -327,9 +352,9 @@ emit) (defn compile-regex [source] - (def [ast ngroups ml] (parse source)) + (def [ast ngroups ml refs] (parse source)) (def grammar @{}) - (def emit (make-emitter grammar ml)) + (def emit (make-emitter grammar ml refs)) # group 0 = whole match: mark start, body, mark end (def body (emit ast ~(sequence (/ (position) ,(fn [p] [0 :e p])) 0))) (put grammar :main ~(sequence (/ (position) ,(fn [p] [0 :s p])) ,body)) @@ -354,8 +379,11 @@ (def starts (array/new-filled (+ ngroups 1))) (def ends (array/new-filled (+ ngroups 1))) (each m marks - (let [n (m 0)] - (if (= (m 1) :s) (put starts n (m 2)) (put ends n (m 2))))) + # backreferenced groups also leave a tagged TEXT capture (a string) on the + # stack — skip those; only the [n :s/:e pos] position tuples carry group spans + (when (indexed? m) + (let [n (m 0)] + (if (= (m 1) :s) (put starts n (m 2)) (put ends n (m 2)))))) (def groups (array/new-filled (+ ngroups 1))) (var n 0) (while (<= n ngroups) @@ -401,10 +429,14 @@ # `(if-let [m (re-seq ...)] ...)` works — an empty seq would be truthy. (if (= 0 (length out)) nil out)) -(defn re-split [re s] +(defn re-split [re s &opt limit] + # limit (Java Pattern.split n>0): at most `limit` parts — stop after limit-1 + # splits and keep the rest of the string as the final unsplit part. nil/<=0 + # splits at every match. (def re (re-pattern re)) + (def lim (if (and limit (> limit 0)) limit nil)) (def out @[]) (var pos 0) (var last 0) - (while (<= pos (length s)) + (while (and (<= pos (length s)) (or (nil? lim) (< (length out) (dec lim)))) (def g (match-at re s pos)) (if (and g (> (length (in g 0)) 0)) (do (array/push out (string/slice s last pos)) diff --git a/test/spec/host-interop-spec.janet b/test/spec/host-interop-spec.janet index ca7ed39..17e1b55 100644 --- a/test/spec/host-interop-spec.janet +++ b/test/spec/host-interop-spec.janet @@ -159,6 +159,8 @@ "[(Boolean/parseBoolean \"true\") (Boolean/parseBoolean \"false\") (Boolean/parseBoolean \"yes\")]"] ["System/getenv is a map" "true" "(string? (get (System/getenv) \"HOME\"))"] + # registered, so (System/exit n) resolves; actual exit verified via subprocess + ["System/exit resolves" "true" "(fn? System/exit)"] # NOT every? alone — it held vacuously while seq over a raw host table # yielded nothing, hiding that read-system-env came back empty ["getenv entries destructure (non-empty)" "true" diff --git a/test/spec/regex-spec.janet b/test/spec/regex-spec.janet index 264a2ef..78de377 100644 --- a/test/spec/regex-spec.janet +++ b/test/spec/regex-spec.janet @@ -62,3 +62,22 @@ [".matches partial -> false" "false" "(.matches \"abcd\" \"a.c\")"] [".replaceAll regex" "\"a-b-c\"" "(.replaceAll \"a_b_c\" \"_\" \"-\")"] [".replaceFirst regex" "\"a-b_c\"" "(.replaceFirst \"a_b_c\" \"_\" \"-\")"]) + +(defspec "regex / bounded quantifiers (jolt-3xur)" + ["exact {n}" "\"aaa\"" "(re-matches #\"a{3}\" \"aaa\")"] + ["range {n,m} max" "\"aaaa\"" "(re-find #\"a{2,4}\" \"aaaaa\")"] + ["zero lower {0,n}" "\"aa\"" "(re-find #\"a{0,3}\" \"aa\")"] + ["{n,} unbounded" "\"aaaa\"" "(re-find #\"a{2,}\" \"aaaa\")"] + # nested bounded quantifiers must not blow up the PEG compiler — this used to + # expand to ~2^61 nodes and hang at compile time. + ["nested bounds compile" "true" + "(boolean (re-matches #\"[a-z](?:[a-z]{0,61}[a-z])?(?:-[a-z]{0,61}[a-z])*\" \"abc-def\"))"]) + +(defspec "regex / backreferences (jolt-xtss)" + ["repeated char" "true" "(boolean (re-find #\"(.)\\1\" \"abba\"))"] + ["no repeat = nil" "nil" "(re-find #\"(.)\\1\" \"abc\")"] + ["thematic-break run" "true" "(boolean (re-matches #\"([-*_])\\1\\1\" \"---\"))"] + ["mismatched run" "nil" "(re-matches #\"([-*_])\\1\\1\" \"-*_\")"] + ["repeated word" "[\"the the\" \"the\"]" "(re-find #\"(\\w+) \\1\" \"say the the word\")"] + ["group still captures" "[\"x=x\" \"x\"]" "(re-matches #\"(\\w+)=\\1\" \"x=x\")"] + ["html tag pair" "true" "(boolean (re-matches #\"<(\\w+)>.*\" \"hi\"))"]) diff --git a/test/spec/strings-spec.janet b/test/spec/strings-spec.janet index daeceeb..731734c 100644 --- a/test/spec/strings-spec.janet +++ b/test/spec/strings-spec.janet @@ -20,6 +20,22 @@ ["pr-str of a defn" "\"#'user/gg\"" "(pr-str (defn gg [x] x))"] ["seq of string" "[\\a \\b]" "(seq \"ab\")"]) +(defspec "string as a seqable of chars (jolt-dl4s)" + ["vec of string" "[\\a \\b]" "(vec \"ab\")"] + ["into [] of string" "[\\a \\b]" "(into [] \"ab\")"] + ["set of string" "true" "(= #{\\a \\b} (set \"ab\"))"] + ["into #{} of string" "true" "(= #{\\a \\b} (into #{} \"ab\"))"] + ["set dedups chars" "2" "(count (set \"aab\"))"] + ["mapv over string" "[\\a \\b]" "(mapv identity \"ab\")"]) + +(defspec "clojure.string / split limit (jolt-ik3a)" + ["neg keeps trailing" "[\"a\" \"\" \"\"]" "(do (require (quote [clojure.string :as s])) (s/split \"a,,\" #\",\" -1))"] + ["zero trims trailing" "[\"a\"]" "(do (require (quote [clojure.string :as s])) (s/split \"a,,\" #\",\" 0))"] + ["omitted trims" "[\"a\"]" "(do (require (quote [clojure.string :as s])) (s/split \"a,,\" #\",\"))"] + ["positive caps parts" "[\"a\" \"b,c\"]" "(do (require (quote [clojure.string :as s])) (s/split \"a,b,c\" #\",\" 2))"] + ["empty string" "[\"\"]" "(do (require (quote [clojure.string :as s])) (s/split \"\" #\",\"))"] + ["interior empties kept" "[\"a\" \"\" \"b\"]" "(do (require (quote [clojure.string :as s])) (s/split \"a,,b\" #\",\"))"]) + (defspec "clojure.string" ["join" "\"a,b,c\"" "(do (require (quote [clojure.string :as s])) (s/join \",\" [\"a\" \"b\" \"c\"]))"] ["join no sep" "\"abc\"" "(do (require (quote [clojure.string :as s])) (s/join [\"a\" \"b\" \"c\"]))"]