regex: \p{...} property classes; interop: the String method surface (cuerdas is green)

\p{L}/\p{Lu}/\p{Ll}/\p{N}/\p{Z}/\p{Ps}/\p{Pe} (+\P negation) land in
both escape positions of the regex compiler, mapped onto the byte PEGs:
ASCII exact, any high byte (inside a UTF-8 sequence) counts as a LETTER —
so ^\p{L}+$ accepts UTF-8 words while \p{N}/\p{Z} stay ASCII. (?u) was
already a tolerated no-op flag. Unknown property names error at compile.

Chasing the acceptance target (cuerdas via deps-conformance) pulled in the
rest of its clj-compat chain, each a real gap:
- the deps-conformance harness reads libraries under clj-compat reader
  features (deps are clj/cljc by definition — without :clj, cuerdas's
  #?(:clj (instance? Pattern x)) branches resolved to NIL bodies)
- instance? knows Pattern/java.util.regex.Pattern (regex values) and
  Character (cuerdas's rx/regexp? gate on split)
- the java.lang.String method surface: .toLowerCase/.toUpperCase/.trim/
  .indexOf(-1 on miss)/.lastIndexOf/.substring/.charAt/.startsWith/
  .endsWith/.contains/.replace/.equalsIgnoreCase/... — ASCII case mapping,
  unknown methods error (the old path silently returned nil)
- the (.method obj args) SUGAR now desugars to (. obj method args) in the
  interpreter — it was never implemented (bare .method heads resolved as
  vars, hence 'Cannot call nil')
- Long/MAX_VALUE / MIN_VALUE statics (f64 approximations)

deps-conformance: medley ok, cuerdas ok (was check-error); dependency now
loads its clj branches and fails only on its single-segment ns resolution.
30 new spec rows (11 regex, 19 interop). Gate exit 0.
This commit is contained in:
Yogthos 2026-06-10 21:29:22 -04:00
parent 621ca5cf75
commit 1898df99bc
6 changed files with 173 additions and 19 deletions

View file

@ -432,6 +432,46 @@
{"currentTimeMillis" (fn [] (math/floor (* 1000 (os/clock :realtime))))
"nanoTime" (fn [] (math/floor (* 1e9 (os/clock :monotonic))))})
# Long statics: sentinels portable code compares against. jolt numbers are
# doubles, so these are the f64 approximations.
(def- long-statics
{"MAX_VALUE" 9223372036854775807
"MIN_VALUE" -9223372036854775808})
# java.lang.String method surface for clj-compat interop: (.toLowerCase s),
# (.indexOf s x), ... — the methods portable cljc libraries actually call.
# Case mapping is ASCII (the whole engine is byte-based); indexOf returns -1
# on miss, as on the JVM.
(defn- str-needle [x]
(if (and (struct? x) (= :jolt/char (get x :jolt/type)))
(string/from-bytes (x :ch))
(string x)))
(def- string-methods
{"toString" (fn [s] s)
"toLowerCase" (fn [s] (string/ascii-lower s))
"toUpperCase" (fn [s] (string/ascii-upper s))
"trim" (fn [s] (string/trim s))
"length" (fn [s] (length s))
"isEmpty" (fn [s] (= 0 (length s)))
"charAt" (fn [s i] {:jolt/type :jolt/char :ch (s i)})
"codePointAt" (fn [s i] (s i))
"indexOf" (fn [s x &opt from] (or (string/find (str-needle x) s (or from 0)) -1))
"lastIndexOf" (fn [s x]
(let [n (str-needle x)]
(var found -1) (var i 0)
(while (< i (length s))
(let [f (string/find n s i)]
(if f (do (set found f) (set i (+ f 1))) (set i (length s)))))
found))
"substring" (fn [s start &opt end] (string/slice s start end))
"startsWith" (fn [s p] (string/has-prefix? p s))
"endsWith" (fn [s p] (string/has-suffix? p s))
"contains" (fn [s sub] (not (nil? (string/find (str-needle sub) s))))
"concat" (fn [s o] (string s o))
"replace" (fn [s a b] (string/replace-all (str-needle a) (str-needle b) s))
"compareTo" (fn [s o] (cond (< s o) -1 (> s o) 1 0))
"equalsIgnoreCase" (fn [s o] (= (string/ascii-lower s) (string/ascii-lower (string o))))})
(defn- resolve-sym
[ctx bindings sym-s]
(let [name (sym-s :name) ns (sym-s :ns)]
@ -444,6 +484,9 @@
(if (= ns "System")
(let [v (get system-statics name)]
(if (nil? v) (error (string "Unsupported System member: System/" name)) v))
(if (= ns "Long")
(let [v (get long-statics name)]
(if (nil? v) (error (string "Unsupported Long member: Long/" name)) v))
(if (not (nil? ns))
(let [current-ns (ctx-find-ns ctx (ctx-current-ns ctx))
aliased-ns (or (ns-alias-lookup current-ns ns) (ns-import-lookup current-ns ns))
@ -493,7 +536,7 @@
# No implicit Janet fallback (Stage 3): an unresolved
# Clojure symbol is an error. Host access is the explicit
# janet/ prefix above.
(error (string "Unable to resolve symbol: " name))))))))))))))))
(error (string "Unable to resolve symbol: " name)))))))))))))))))
(defn- parse-arg-names
"Parse a parameter vector, handling & rest args.
Returns {:fixed [names...] :rest name-or-nil :all [names...]}"
@ -1192,6 +1235,11 @@
"java.lang.String" (string? val)
"Boolean" (or (= true val) (= false val))
"Keyword" (keyword? val)
# regex patterns (cuerdas-style (instance? Pattern x) checks)
"Pattern" (and (table? val) (= :jolt/regex (val :jolt/type)))
"java.util.regex.Pattern" (and (table? val) (= :jolt/regex (val :jolt/type)))
"Character" (and (struct? val) (= :jolt/char (get val :jolt/type)))
"java.lang.Character" (and (struct? val) (= :jolt/char (get val :jolt/type)))
"clojure.lang.Atom" (and (table? val) (= :jolt/atom (val :jolt/type)))
"clojure.lang.Volatile" (and (table? val) (= :jolt/volatile (val :jolt/type)))
"clojure.lang.Delay" (and (table? val) (= :jolt/delay (val :jolt/type)))
@ -1762,6 +1810,11 @@
(if (> (length form) 3)
# method call: (. obj method args...)
(let [args (map |(eval-form ctx bindings $) (tuple/slice form 3))]
(if (or (string? target) (buffer? target))
(let [m (get string-methods field-name)]
(if m
(m (string target) ;args)
(error (string "Unsupported String method ." field-name))))
(if (target :jolt/deftype)
(let [method-key (keyword field-name)]
(apply (get target method-key) target ;args))
@ -1777,10 +1830,16 @@
(method-fn target ;args)
(error (string "Cannot call non-function " field-name " on " (type target)))))
(error (string "Cannot call non-function " field-name " on " (type target))))))
(error (string "Cannot call method " field-name " on " (type target))))))
(error (string "Cannot call method " field-name " on " (type target)))))))
# (. obj member) with no extra args: a symbol member naming a
# function is a zero-arg method call (receiver passed as self);
# a keyword or `-field` member is plain field access.
# a keyword or `-field` member is plain field access. Strings get
# the java.lang.String surface (clj-compat: (.toLowerCase s) ...).
(if (or (string? target) (buffer? target))
(let [m (get string-methods field-name)]
(if m
(m (string target))
(error (string "Unsupported String method ." field-name))))
(let [v (get target (keyword field-name))]
(if (and (struct? member-raw) (= :symbol (member-raw :jolt/type))
(not (string/has-prefix? "-" member-name)))
@ -1790,7 +1849,7 @@
(array? v) (let [f (eval-form ctx bindings v)]
(if (or (function? f) (cfunction? f)) (f target) f))
v)
v))))
v)))))
# default: function application — check for macros
(if (and (struct? first-form) (= :symbol (first-form :jolt/type)))
(let [sym-name (first-form :name)]
@ -1800,6 +1859,18 @@
(let [field-name (string/slice sym-name 2)
target (eval-form ctx bindings (in form 1))]
(get target (keyword field-name)))
# (.method obj args...) sugar -> (. obj method args...): desugar and
# re-enter the dot special form (which holds the String surface, the
# deftype method path, and the map-fn fallback).
(if (and (> (length sym-name) 1)
(= (string/slice sym-name 0 1) ".")
(not= sym-name "..")
(> (length form) 1))
(eval-form ctx bindings
(array/concat @[{:jolt/type :symbol :ns nil :name "."}
(in form 1)
{:jolt/type :symbol :ns nil :name (string/slice sym-name 1)}]
(tuple/slice form 2)))
# Handle ClassName. constructor syntax (".." is the member-threading
# macro, not a constructor named ".")
(if (and (> (length sym-name) 1) (not= sym-name "..")
@ -1821,7 +1892,7 @@
(eval-form ctx bindings expanded))))
(let [f (eval-form ctx bindings first-form)
args (map |(eval-form ctx bindings $) (tuple/slice form 1))]
(jolt-invoke ctx f args)))))))
(jolt-invoke ctx f args))))))))
(let [f (eval-form ctx bindings first-form)
args (map |(eval-form ctx bindings $) (tuple/slice form 1))]
(jolt-invoke ctx f args)))))

View file

@ -40,6 +40,40 @@
(chr "S") '(if-not (set " \t\n\r\f\v") 1)
nil))
# Unicode property classes \p{...} (jolt-xlp), mapped onto the byte PEGs:
# ASCII exactly; any high byte (>= 0x80, i.e. inside a UTF-8 sequence) counts
# as a LETTER byte — so ^\p{L}+$ accepts UTF-8 words, while \p{N}/\p{Z}
# stay ASCII-only. Lu/Ll are ASCII (case is byte-based throughout this
# engine). Unknown property names error at compile.
(defn- prop-frag [name]
(case name
"L" '(choice (range "az" "AZ") (range "\x80\xFF"))
"Lu" '(range "AZ")
"Ll" '(range "az")
"N" '(range "09")
"Nd" '(range "09")
"Z" '(set " ")
"Zs" '(set " ")
"P" '(set "!\"#%&'()*,-./:;?@[\\]_{}")
"Ps" '(set "([{")
"Pe" '(set ")]}")
"Alpha" '(choice (range "az" "AZ") (range "\x80\xFF"))
"Digit" '(range "09")
nil))
# At s[i] = backslash: if this is \p{Name} / \P{Name}, return [peg-frag end-i]
# (end-i past the closing brace), else nil.
(defn- parse-prop [s i]
(def pc (get s (+ i 1)))
(when (and (or (= pc (chr "p")) (= pc (chr "P")))
(= (get s (+ i 2)) (chr "{")))
(def close (string/find "}" s (+ i 3)))
(unless close (error "regex: unterminated \\p{...}"))
(def nm (string/slice s (+ i 3) close))
(def frag (prop-frag nm))
(unless frag (error (string "regex: unsupported property class \\p{" nm "}")))
[(if (= pc (chr "P")) ~(if-not ,frag 1) frag) (+ close 1)]))
(defn- esc-byte [c]
(case c
(chr "n") 10 (chr "t") 9 (chr "r") 13 (chr "f") 12 (chr "v") 11 (chr "0") 0
@ -66,12 +100,14 @@
"lower" '(range "az")
'(set "")))
(set i (+ close 2)))
# escape inside class
# escape inside class — \p{...} first (multi-char), then 2-char escapes
(= (s i) (chr "\\"))
(let [c (s (+ i 1)) p (pred-frag c)]
(if p (array/push alts p)
(array/push alts ~(set ,(string/from-bytes (esc-byte c)))))
(set i (+ i 2)))
(if-let [pr (parse-prop s i)]
(do (array/push alts (pr 0)) (set i (pr 1)))
(let [c (s (+ i 1)) p (pred-frag c)]
(if p (array/push alts p)
(array/push alts ~(set ,(string/from-bytes (esc-byte c)))))
(set i (+ i 2))))
# range a-z
(and (< (+ i 2) (length s)) (= (s (+ i 1)) (chr "-")) (not= (s (+ i 2)) (chr "]")))
(do
@ -139,13 +175,15 @@
(= c (chr "."))
(do (++ (st :pos)) {:op :any :dotall (st :dotall)})
(= c (chr "\\"))
(let [nc (s (+ (st :pos) 1)) p (pred-frag nc)]
(+= (st :pos) 2)
(cond
p {:op :pred :peg p}
(= nc (chr "b")) {:op :anchor :kind :wordb}
(= nc (chr "B")) {:op :anchor :kind :nwordb}
{:op :char :b (esc-byte nc) :ci ci}))
(if-let [pr (parse-prop s (st :pos))]
(do (set (st :pos) (pr 1)) {:op :pred :peg (pr 0)})
(let [nc (s (+ (st :pos) 1)) p (pred-frag nc)]
(+= (st :pos) 2)
(cond
p {:op :pred :peg p}
(= nc (chr "b")) {:op :anchor :kind :wordb}
(= nc (chr "B")) {:op :anchor :kind :nwordb}
{: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})
(do (++ (st :pos)) {:op :char :b c :ci ci})))

View file

@ -7,6 +7,11 @@
(use ../../src/jolt/api)
(use ../../src/jolt/types)
(import ../../src/jolt/deps :as deps)
(use ../../src/jolt/reader)
# deps are clj/cljc libraries by definition (the jolt-dw4 premise): read them
# under clj-compat features so their #?(:clj ...) branches resolve (spec
# 02-reader S18 — features are a property of the loading context).
(reader-features-set! ["jolt" "clj" "default"])
(unless (os/getenv "JOLT_CONFORMANCE")
(print "deps-conformance: set JOLT_CONFORMANCE=1 to run (needs network) — skipped")

View file

@ -38,3 +38,26 @@
["aset returns val" "9" "(aset (object-array [1 2 3]) 1 9)"]
["aset mutates" "[7 2 3]" "(let [a (object-array [1 2 3])] (aset a 0 7) (vec a))"]
["aget 2d" "4" "(aget (to-array-2d [[1 2] [3 4]]) 1 1)"])
# java.lang.String surface + .method sugar (clj-compat: what portable cljc
# libraries call — landed for the cuerdas acceptance run). ASCII case mapping.
(defspec "interop / String methods"
[".toLowerCase" "\"hi\"" "(.toLowerCase \"HI\")"]
[".toUpperCase" "\"HI\"" "(.toUpperCase \"hi\")"]
["dot-form" "\"hi\"" "(. \"HI\" toLowerCase)"]
[".trim" "\"x\"" "(.trim \" x \")"]
[".length" "3" "(.length \"abc\")"]
[".isEmpty" "[true false]" "[(.isEmpty \"\") (.isEmpty \"a\")]"]
[".indexOf hit" "1" "(.indexOf \"abc\" \"b\")"]
[".indexOf miss is -1" "-1" "(.indexOf \"abc\" \"z\")"]
[".lastIndexOf" "3" "(.lastIndexOf \"abab\" \"b\")"]
[".substring" "\"bc\"" "(.substring \"abc\" 1)"]
[".substring end" "\"b\"" "(.substring \"abc\" 1 2)"]
[".startsWith" "true" "(.startsWith \"abc\" \"ab\")"]
[".endsWith" "true" "(.endsWith \"abc\" \"bc\")"]
[".contains" "true" "(.contains \"abc\" \"b\")"]
[".replace" "\"axc\"" "(.replace \"abc\" \"b\" \"x\")"]
[".charAt" "\\b" "(.charAt \"abc\" 1)"]
[".equalsIgnoreCase" "true" "(.equalsIgnoreCase \"AbC\" \"aBc\")"]
["Long/MAX_VALUE" "true" "(pos? Long/MAX_VALUE)"]
["unsupported method throws" :throws "(.frobnicate \"abc\")"])

View file

@ -31,3 +31,19 @@
["split on regex" "[\"a\" \"b\" \"c\"]" "(do (require '[clojure.string :as s]) (s/split \"a1b2c\" #\"\\d\"))"]
["replace regex" "\"X-X\"" "(do (require '[clojure.string :as s]) (s/replace \"a-b\" #\"[a-z]\" \"X\"))"]
["replace $1" "\"[a][b]\"" "(do (require '[clojure.string :as s]) (s/replace \"ab\" #\"([a-z])\" \"[$1]\"))"])
# Unicode property classes (jolt-xlp), byte-PEG approximation: ASCII exact,
# any high byte (inside a UTF-8 sequence) counts as a LETTER for \p{L}.
# Acceptance target was cuerdas (kebab/snake/capital now pass conformance).
(defspec "regex / \\p property classes"
["p{L} ascii" "\"hello\"" `(re-matches #"^\p{L}+$" "hello")`]
["p{L} utf-8" "true" `(boolean (re-matches #"^\p{L}+$" "héllo"))`]
["p{L} rejects digits" "false" `(boolean (re-matches #"^\p{L}+$" "ab1"))`]
["p{N}" "(quote (\"12\" \"345\"))" `(re-seq #"\p{N}+" "a12b345")`]
["P{N} negation" "\"abc\"" `(re-matches #"^\P{N}+$" "abc")`]
["inside class" "\"a-1_b\"" `(re-matches #"^[\p{N}\p{L}_-]+$" "a-1_b")`]
["p{Lu}/p{Ll}" "\"aB\"" `(re-matches #"^\p{Ll}\p{Lu}$" "aB")`]
["p{Z} space" "\" \"" `(re-matches #"(?u)^[\s\p{Z}]+$" " ")`]
["p{Ps}/p{Pe}" "\"(x)\"" `(re-matches #"^\p{Ps}x\p{Pe}$" "(x)")`]
["(?u) accepted" "\"hi\"" `(re-matches #"(?u)^hi$" "hi")`]
["unknown class throws" :throws `(re-pattern "\p{Greek}")`])

View file

@ -129,8 +129,9 @@
["delay? true" "true" "(delay? (delay 1))"]
["delay? false" "false" "(delay? 1)"]
["future-call" "42" "(deref (future-call (fn [] 42)))"]
[". member resolves nil" "nil" "(. \"abc\" count)"]
[".. threads members" "nil" "(.. \"abc\" count)"])
[". calls String surface" "3" "(. \"abc\" length)"]
[".. threads members" "\"ABC\"" "(.. \"abc\" toUpperCase)"]
["unknown String member throws" :throws "(. \"abc\" frobnicate)"])
(defspec "untested / protocols: extend + extends?"
["extend registers" ":str"