(ns kotoba.lang.text
  "String / regex / unicode helpers for the kotoba foundational stdlib. The gap
  every other lib re-rolls (json/lint/time hand-roll string ops). Pure string
  ops are portable; regex uses the host's #\"...\". `format` is a pure printf
  impl with flags/width/precision (no String/format — WASM-safe), varargs like
  clojure.core/format, which is JVM-only. Runs on JVM/SCI/CLJS/GraalVM/kotoba-WASM.

  Zero third-party runtime deps; .cljc."
  {:kotoba/export '[blank-text? blank? capitalize codepoints ends-with? escape format from-codepoints includes? index-of index-of-text join last-index-of last-index-of-text lower pad-center-text pad-left pad-left-text pad-right pad-right-text re-find re-matches re-quote re-seq repeat-text replace replace-first replace-first-text reverse reverse-text segment-count-text segment-text split split-lines starts-with? trim trim-newline trim-newline-text trim-text triml triml-text trimr trimr-text truncate upper]}
  (:refer-clojure :exclude [format split join replace re-find re-matches re-seq
                            reverse]))

;; ---------------------------------------------------------------------------
;; Self-hosted. This namespace does NOT require clojure.string.
;; ---------------------------------------------------------------------------
;;
;; It used to. Delegating was the honest first move -- it made the surface
;; complete without claiming semantics nobody had written down. But it meant
;; that "migrate the workspace to kotoba.*" bought a rename and nothing else:
;; the host dependency was still there, one layer down, in the one place every
;; caller reached through.
;;
;; Two things a delegating wrapper cannot do, which are the reason this is
;; worth the code:
;;
;;   1. `clojure.string` DOES NOT MEAN THE SAME THING ON ITS TWO HOSTS, and a
;;      wrapper inherits the disagreement silently. `trim` is the clearest
;;      case: on the JVM it strips what `Character/isWhitespace` accepts, which
;;      EXCLUDES the non-breaking spaces U+00A0, U+2007 and U+202F and INCLUDES
;;      the C0 separators U+001C-U+001F. On ClojureScript it is
;;      `goog.string/trim`, i.e. /[\s\xa0]+/, which is the other way round on
;;      both counts. The same call, the same input, two answers. Below, the
;;      whitespace class is a named, closed set, so there is one answer.
;;
;;   2. Nothing downstream can be compiled for a target that has no
;;      `clojure.string` at all.
;;
;; Where the two hosts disagreed, this namespace adopts the JVM answer and
;; pins it in a test, because that is what the existing call sites were
;; written against. Every divergence is named in the docstring of the function
;; it affects -- none of it is discovered at a call site.
;;
;; Regex stays host regex (`#"..."`). That was never clojure.string's; the
;; `match-spans` primitive below reaches the host's own matcher directly, so
;; `split`/`replace` are built here rather than delegated.

;; ---------- the host's regex matcher, and nothing else from the host --------

(defn- match-spans
  "Every non-overlapping match of `re` in `s`, in order, as
  `{:start i :end j :groups [whole g1 g2 ...]}` with character indices.

  This is the single point where this namespace touches the host's regex
  engine. A zero-length match advances by one character, exactly as
  `java.util.regex.Matcher/find` does, so `#\"\"` against \"ab\" yields three
  empty matches (before a, before b, at the end) on both hosts rather than
  looping forever on one of them."
  [re s]
  #?(:clj
     (let [m (re-matcher re s)]
       (loop [acc []]
         (if (.find m)
           (recur (conj acc {:start  (.start m)
                             :end    (.end m)
                             :groups (mapv #(.group m ^int %)
                                           (range (inc (.groupCount m))))}))
           acc)))
     :cljs
     (let [flags (.-flags re)
           g     (js/RegExp. (.-source re)
                             (if (.includes flags "g") flags (str flags "g")))]
       (loop [acc []]
         (let [m (.exec g s)]
           (if (nil? m)
             acc
             (let [start (.-index m)
                   whole (aget m 0)
                   end   (+ start (.-length whole))]
               (when (= start end)
                 (set! (.-lastIndex g) (inc start)))
               (recur (conj acc {:start  start
                                 :end    end
                                 :groups (mapv #(aget m %) (range (.-length m)))})))))))))

;; ---------- split / join ----------

(defn join
  "Join `coll` with separator `sep`. With one argument, joins with no
  separator. `nil` elements render as the empty string, like `str`."
  ([coll] (apply str coll))
  ([sep coll] (apply str (interpose sep coll))))

(defn split
  "Split `s` on regex `re`, returning a vector of substrings.

  `java.util.regex.Pattern/split` semantics, reimplemented rather than
  delegated:

  * `limit` absent or 0 -- trailing empty strings are removed.
  * `limit` positive -- at most `limit` parts; the last one is the whole
    remainder, unsplit.
  * `limit` negative -- all parts, trailing empties kept.
  * a zero-width match at index 0 does not produce a leading empty string.
  * no match anywhere yields `[s]`, never `[]`."
  ([s re] (split s re 0))
  ([s re limit]
   (let [s       (str s)
         limited (pos? limit)
         spans   (match-spans re s)]
     (loop [spans spans, index 0, parts []]
       (if-let [{:keys [start end]} (first spans)]
         (cond
           ;; a zero-width match at the very beginning contributes nothing
           (and (zero? index) (zero? start) (= start end))
           (recur (next spans) index parts)

           (and limited (= (count parts) (dec limit)))
           (recur nil index parts)

           (or (not limited) (< (count parts) (dec limit)))
           (recur (next spans) end (conj parts (subs s index start)))

           :else (recur nil index parts))
         (if (and (zero? index) (empty? parts))
           [s]
           (let [parts (conj parts (subs s index))]
             (if (zero? limit)
               (loop [p parts]
                 (if (and (seq p) (= "" (peek p))) (recur (pop p)) p))
               parts))))))))

(defn split-lines
  "Split `s` on \\n or \\r\\n. Exactly `clojure.string/split-lines`, which is
  `(split s #\"\\r?\\n\")` -- so a LONE carriage return is not a separator, and
  the trailing empty strings a final newline would produce are dropped."
  [s]
  (split (str s) #"\r?\n"))

;; ---------- trim ----------

(defn- java-whitespace?
  "The whitespace class this namespace trims: exactly what
  `java.lang.Character/isWhitespace` accepts, written out.

  Included: the C0 controls U+0009-U+000D and the four separators
  U+001C-U+001F, SPACE, and the Unicode space separators.
  DELIBERATELY EXCLUDED: the non-breaking spaces U+00A0, U+2007 and U+202F --
  Java does not consider them whitespace, JavaScript's `\\s` does, and this
  namespace answers the same on both hosts by choosing Java's."
  [ch]
  (let [c #?(:clj (int ch) :cljs (.charCodeAt (str ch) 0))]
    (or (<= 9 c 13)
        (<= 28 c 31)
        (= c 32)
        (= c 0x1680)
        (<= 0x2000 c 0x2006)
        (<= 0x2008 c 0x200A)
        (= c 0x2028)
        (= c 0x2029)
        (= c 0x205F)
        (= c 0x3000))))

(defn trim
  "Remove whitespace from both ends of `s`. See `java-whitespace?` for the
  class, which is Java's and is the same on every host here."
  [s]
  (let [s (str s)
        n (count s)]
    (loop [r n]
      (if (zero? r)
        ""
        (if (java-whitespace? (nth s (dec r)))
          (recur (dec r))
          (loop [l 0]
            (if (java-whitespace? (nth s l))
              (recur (inc l))
              (subs s l r))))))))

(defn triml
  "Remove whitespace from the left end of `s`."
  [s]
  (let [s (str s) n (count s)]
    (loop [l 0]
      (if (and (< l n) (java-whitespace? (nth s l)))
        (recur (inc l))
        (subs s l)))))

(defn trimr
  "Remove whitespace from the right end of `s`."
  [s]
  (let [s (str s)]
    (loop [r (count s)]
      (if (and (pos? r) (java-whitespace? (nth s (dec r))))
        (recur (dec r))
        (subs s 0 r)))))

;; ---------- case ----------

(defn upper
  "Upper-case `s` using the host's default locale, like
  `clojure.string/upper-case`."
  [s] #?(:clj (.toUpperCase ^String (str s)) :cljs (.toUpperCase (str s))))

(defn lower
  "Lower-case `s` using the host's default locale."
  [s] #?(:clj (.toLowerCase ^String (str s)) :cljs (.toLowerCase (str s))))

(defn capitalize
  "Upper-case the first character of `s` and lower-case the rest."
  [s]
  (let [s (str s)]
    (if (< (count s) 2)
      (upper s)
      (str (upper (subs s 0 1)) (lower (subs s 1))))))

;; ---------- predicates ----------

(defn starts-with? [s prefix]
  #?(:clj  (.startsWith ^String (str s) ^String (str prefix))
     :cljs (.startsWith (str s) (str prefix))))

(defn ends-with? [s suffix]
  #?(:clj  (.endsWith ^String (str s) ^String (str suffix))
     :cljs (.endsWith (str s) (str suffix))))

(defn includes? [s sub]
  #?(:clj  (.contains ^String (str s) ^CharSequence (str sub))
     :cljs (not= -1 (.indexOf (str s) (str sub)))))

;; ---------- regex-driven rewriting ----------

(defn- expand-template
  "Expand a replacement TEMPLATE against one match's `groups`, using
  `java.util.regex.Matcher` rules:

  * `$N` is group N. Digits are consumed greedily while the number they form
    is still a group the pattern has, so with one group `$12` means group 1
    followed by a literal `2` -- Java's rule, reproduced rather than
    approximated.
  * a reference to a group the pattern does NOT have is refused. Java throws
    IndexOutOfBoundsException here; refusing is better than silently expanding
    to the empty string, which turns a template typo into missing output. The
    refusal is an ex-info with a `:type`, not a host exception class, so a
    caller can catch it the same way on every host.
  * a group the pattern HAS but that did not participate in this match
    expands to nothing -- also Java's behaviour, and not the same case.
  * `\\$` is a literal `$`, `\\\\` a literal backslash.

  Deliberately NOT the ClojureScript behaviour: there, clojure.string hands
  the template to JS `String.replace`, which also understands `$&`, ``$` ``
  and `$'`. Here those are ordinary text on every host."
  [template groups]
  (let [t           (str template)
        n           (count t)
        group-count (dec (count groups))
        digit-at    (fn [j]
                      (when (< j n)
                        (let [c #?(:clj (int (nth t j)) :cljs (.charCodeAt t j))]
                          (when (<= 48 c 57) (- c 48)))))]
    (loop [i 0, out []]
      (if (>= i n)
        (apply str out)
        (let [c (nth t i)]
          (cond
            (and (= c \\) (< (inc i) n))
            (recur (+ i 2) (conj out (nth t (inc i))))

            (and (= c \$) (digit-at (inc i)))
            (let [first-digit (digit-at (inc i))]
              (when (> first-digit group-count)
                (throw (ex-info (str "no group " first-digit " in the pattern")
                                {:type :text/no-such-group
                                 :group first-digit
                                 :group-count group-count
                                 :template t})))
              ;; grow the reference while it still names a real group
              (let [[ref end-idx]
                    (loop [ref first-digit, j (+ i 2)]
                      (let [d     (digit-at j)
                            grown (when d (+ (* ref 10) d))]
                        (if (and grown (<= grown group-count))
                          (recur grown (inc j))
                          [ref j])))]
                (recur end-idx (conj out (or (get groups ref) "")))))

            :else (recur (inc i) (conj out c))))))))

(defn- replacement-for
  "The text one REGEX match contributes: a function is called with the match
  (the whole match when the pattern has no groups, the group vector when it
  has), a string is expanded as a template."
  [replacement groups]
  (if (fn? replacement)
    (str (replacement (if (= 1 (count groups)) (first groups) groups)))
    (expand-template replacement groups)))

(defn- rewrite
  "Shared engine for `replace` and `replace-first`."
  [s match replacement all?]
  (let [s (str s)]
    (if (string? match)
      ;; a literal match needs no regex at all
      (if (empty? match)
        s
        (loop [i 0, out []]
          (let [hit (loop [j i]
                      (cond
                        (> (+ j (count match)) (count s)) nil
                        (= (subs s j (+ j (count match))) match) j
                        :else (recur (inc j))))]
            (if (nil? hit)
              (apply str (conj out (subs s i)))
              ;; A string match is LITERAL on both sides: clojure.string
              ;; routes it to String.replace, so `$1` in the replacement is a
              ;; dollar and a one, not a group reference. Only a regex match
              ;; gets template expansion.
              (let [out (conj out (subs s i hit)
                              (if (fn? replacement)
                                (str (replacement match))
                                (str replacement)))
                    i'  (+ hit (count match))]
                (if all?
                  (recur i' out)
                  (apply str (conj out (subs s i')))))))))
      (let [spans (match-spans match s)
            spans (if all? spans (take 1 spans))]
        (loop [spans spans, index 0, out []]
          (if-let [{:keys [start end groups]} (first spans)]
            (recur (next spans) end
                   (conj out (subs s index start)
                         (replacement-for replacement groups)))
            (apply str (conj out (subs s index)))))))))

(defn replace
  "Replace all matches of `match` in `s` with `replacement`.

  `match` is a regex or a literal string; `replacement` is a string template
  (see `expand-template` for the `$N` rules, which are the JVM's) or a
  function of the match."
  [s match replacement] (rewrite s match replacement true))

(defn replace-first
  "Replace the first match of `match` in `s` with `replacement`."
  [s match replacement] (rewrite s match replacement false))

(defn re-find    [re s] (#?(:clj clojure.core/re-find :cljs cljs.core/re-find) re s))
(defn re-matches [re s] (#?(:clj clojure.core/re-matches :cljs cljs.core/re-matches) re s))
(defn re-seq
  "Return a lazy seq of matches of `re` in `s`. Returns nil if no match."
  [re s]
  (#?(:clj clojure.core/re-seq :cljs cljs.core/re-seq) re s))

;; ---------- format (pure printf: flags, width, precision) ----------
;;
;; Replaces clojure.core/format, which is JVM-only -- it wraps String.format,
;; so nbb resolves it to nil and every portable namespace that calls it is
;; pinned to the JVM. Measured 2026-08-18 across this workspace: 673 production
;; .clj files call `format`, second only to `spit`.
;;
;; The earlier implementation here handled bare %s %d %x %f and fell through to
;; a literal for anything else. Measured over the 5,968 specifiers actually in
;; use, 944 of them (16%) carry a width or precision -- %.1f (176), %02x (133),
;; %.2f (119), %-10s (32), %064x (17). Those did not error; they emitted "%."
;; followed by the rest as literal text. Silently wrong output is worse than a
;; missing function, which is why this grammar exists.
;;
;; %064x in particular is how a 32-byte hash is rendered. Getting it wrong is
;; not cosmetic.

(defn- pad
  "Pad `s` to `width` with `fill`, on the left unless `left?`."
  [s width left? fill]
  (let [n (- width (count s))]
    (if (pos? n)
      (let [p (apply str (repeat n fill))]
        (if left? (str s p) (str p s)))
      s)))

(defn- fixed
  "Round `x` to `prec` decimal places without String/format or goog. Uses
  integer arithmetic on the scaled value so the result does not depend on the
  host's float printing."
  [x prec]
  (let [neg? (neg? x)
        x #?(:kotoba (if neg? (- x) x)
             :default (Math/abs (double x)))
        scale #?(:kotoba (loop [remaining prec result 1]
                           (if (pos? remaining)
                             (recur (dec remaining) (* result 10))
                             result))
                 :default (Math/pow 10 prec))
        scaled #?(:kotoba (long (+ (* x scale) 0.5))
                  :default (Math/round (* x scale)))
        i (long (quot scaled scale))
        f (long (- scaled (* i scale)))
        frac (when (pos? prec) (pad (str f) prec false \0))]
    (str (when neg? "-") i (when frac (str "." frac)))))

(defn- to-hex [n]
  #?(:clj (Long/toHexString (long n))
     :cljs (.toString (long n) 16)))

(defn- fmt-one [{:keys [flags width prec conv]} arg]
  (let [left? (includes? flags "-")
        zero? (and (includes? flags "0") (not left?))
        body (case conv
               ;; clojure.core/format renders nil as "null" (it defers to
               ;; String.valueOf), not as the empty string that `str` gives.
               ;; Caught by the differential test, not by reading the code.
               "s" (let [v (if (nil? arg) "null" (str arg))]
                     (if prec (subs v 0 (min prec (count v))) v))
               "d" (str (long arg))
               "x" (to-hex arg)
               "X" (upper (to-hex arg))
               "o" #?(:clj (Long/toOctalString (long arg)) :cljs (.toString (long arg) 8))
               "f" (fixed arg (or prec 6))
               "c" (str (char (if (number? arg) (long arg) arg)))
               "b" (str (boolean arg))
               (str "%" conv))
        body (if (and (includes? flags "+") (#{"d" "f"} conv) (not (starts-with? body "-")))
               (str "+" body) body)]
    (if width (pad body width left? (if zero? \0 \space)) body)))

(def ^:private spec-re #"%([-+ 0#]*)(\d+)?(?:\.(\d+))?([a-zA-Z%])")

(defn format
  "printf-style formatting with flags, width and precision -- %s %d %x %X %o
  %f %c %b and literal %%. VARARGS, like clojure.core/format, which this
  replaces; clojure.core/format is JVM-only.

  A seq may still be passed as a single second argument, which is how this
  function used to be called; that form is kept so existing callers do not
  change meaning. The two are distinguishable because the seq form takes
  exactly one extra argument and it is sequential."
  [fmt & args]
  (let [args (vec (if (and (= 1 (count args)) (sequential? (first args)))
                    (first args)
                    args))]
    (loop [i 0 ai 0 out (transient [])]
      (if (>= i (count fmt))
        (apply str (persistent! out))
        (let [c (nth fmt i)]
          (if (not= c \%)
            (recur (inc i) ai (conj! out c))
            (if-let [m (re-find spec-re (subs fmt i (min (count fmt) (+ i 16))))]
              (let [[whole flags w p conv] m]
                (if (= conv "%")
                  (recur (+ i (count whole)) ai (conj! out \%))
                  (recur (+ i (count whole)) (inc ai)
                         (conj! out (fmt-one {:flags (or flags "")
                                              :width (when w #?(:clj (Long/parseLong w) :cljs (js/parseInt w)))
                                              :prec  (when p #?(:clj (Long/parseLong p) :cljs (js/parseInt p)))
                                              :conv  conv}
                                             (nth args ai nil))))))
              (recur (inc i) ai (conj! out c)))))))))

;; ---------- unicode codepoints ----------

(defn codepoints
  "Return a vector of unicode codepoint ints for `s`. Portable (no
  String/codePoints which is JVM-only)."
  [s]
  (let [n (count s)]
    (loop [i 0 out (transient [])]
      (if (>= i n)
        (persistent! out)
        (let [c (nth s i)
              cp #?(:clj  (long c)
                    :cljs (.charCodeAt c 0))]
          ;; surrogate pair handling (basic): high surrogate + low surrogate
          (if (and (>= cp 0xD800) (<= cp 0xDBFF) (< (inc i) n))
            (let [c2 (nth s (inc i))
                  cp2 #?(:clj  (long c2)
                         :cljs (.charCodeAt c2 0))]
              (if (and (>= cp2 0xDC00) (<= cp2 0xDFFF))
                (let [combined (+ (* (- cp 0xD800) 0x400) (- cp2 0xDC00) 0x10000)]
                  (recur (+ i 2) (conj! out combined)))
                (recur (inc i) (conj! out cp))))
            (recur (inc i) (conj! out cp))))))))

(defn from-codepoints
  "Build a string from a seq of codepoint ints. Surrogates are emitted for
  codepoints > 0xFFFF (portable)."
  [cps]
  (let [f #?(:clj char :cljs js/String.fromCharCode)]
    (apply str
           (mapcat (fn [cp]
                     (if (> cp 0xFFFF)
                       ;; emit a surrogate pair
                       (let [cp' (- cp 0x10000)
                             hi (+ 0xD800 (quot cp' 0x400))
                             lo (+ 0xDC00 (mod cp' 0x400))]
                         [(f hi) (f lo)])
                       [(f cp)]))
                   cps))))

;; ---------- padding / truncate ----------

(defn pad-left  [s width ch] (let [pad (max 0 (- width (count s)))] (str (join (repeat pad ch)) s)))
(defn pad-right [s width ch] (let [pad (max 0 (- width (count s)))] (str s (join (repeat pad ch)))))

(defn truncate
  "Truncate `s` to `max-len` chars. If `ellipsis` is given and `s` is longer
  than `max-len`, the result is `max-len` chars including the ellipsis."
  ([s max-len] (subs s 0 (min (count s) max-len)))
  ([s max-len ellipsis]
   (if (<= (count s) max-len)
     s
     (str (subs s 0 (max 0 (- max-len (count ellipsis)))) ellipsis))))

;; ---------- remaining clojure.string parity ----------
;; blank? / index-of / last-index-of / reverse / trim-newline were the last
;; clojure.string primitives this oracle did not carry, forcing callers back
;; to `clojure.string` directly for them.

(defn blank?
  "True if `s` is nil, empty, or contains only whitespace."
  [s] (or (nil? s) (= "" (trim s))))

(defn index-of
  "Index of the first occurrence of `value` (string or char) in `s`, from
  `from-index` if given, or nil if not found."
  ([s value] (index-of s value 0))
  ([s value from-index]
   (let [i #?(:clj  (.indexOf ^String (str s) ^String (str value) (int from-index))
              :cljs (.indexOf (str s) (str value) from-index))]
     (when-not (neg? i) i))))

(defn last-index-of
  "Index of the last occurrence of `value` (string or char) in `s`, searching
  backward from `from-index` if given, or nil if not found."
  ([s value]
   (let [i #?(:clj  (.lastIndexOf ^String (str s) ^String (str value))
              :cljs (.lastIndexOf (str s) (str value)))]
     (when-not (neg? i) i)))
  ([s value from-index]
   (let [i #?(:clj  (.lastIndexOf ^String (str s) ^String (str value) (int from-index))
              :cljs (.lastIndexOf (str s) (str value) from-index))]
     (when-not (neg? i) i))))

(defn reverse
  "Reverse `s`, keeping surrogate pairs intact -- an astral character comes
  back whole, not as two swapped halves. That matches the JVM's
  `clojure.string/reverse`, which is `StringBuilder.reverse` and is documented
  to treat a surrogate pair as one unit; a naive per-code-unit reversal would
  produce two unpaired surrogates and is what this used to do."
  [s] (from-codepoints (vec (rseq (vec (codepoints (str s)))))))

(defn trim-newline
  "Remove trailing newline (\\n) or carriage-return+newline (\\r\\n) from `s`."
  [s]
  (let [s (str s)]
    (cond
      (ends-with? s "\r\n") (subs s 0 (- (count s) 2))
      (ends-with? s "\n")    (subs s 0 (dec (count s)))
      (ends-with? s "\r")    (subs s 0 (dec (count s)))
      :else s)))

;; ---------- bounded-kernel oracle (2026-09-04) ----------
;;
;; The `.kotoba` kernel (`bounded_text.kotoba`) now owns a trim/reverse/
;; repeat/index/pad tranche composed from the language's string builtins.
;; These CLJC functions are their ORACLE: same names modulo the `-text`
;; suffix, same semantics INCLUDING the differences the bounded kernel
;; names rather than hides -- ASCII-only whitespace (kernel `ws?`),
;; UTF-8 byte offsets instead of UTF-16 code units (kernel index answers),
;; and a fill string instead of a fill char (kernel pad). A caller migrating
;; from `clojure.string` should meet the same divergence in both.

(defn- ascii-ws?
  "The kernel's whitespace class: ASCII space, tab, newline, CR, FF, VT.
  Unicode spaces (U+00A0, U+3000, ...) are NOT whitespace here, where
  clojure.string/trim's Character/isWhitespace answers for them."
  [^long cp]
  (contains? #{32 9 10 13 12 11} cp))

(defn- codepoints-of
  "Code points of s as a vector (text/codepoints, reused here so the oracle
  and kernel walk the same sequence)."
  [s]
  (codepoints s))

(defn trim-text
  "Oracle for the kernel's trim-text: strips ASCII whitespace from both ends."
  [s]
  (let [cps (vec (codepoints-of s))
        n (count cps)
        lead (loop [i 0] (if (and (< i n) (ascii-ws? (nth cps i))) (recur (inc i)) i))
        trail (loop [i (dec n)] (if (and (>= i 0) (ascii-ws? (nth cps i))) (recur (dec i)) i))]
    (if (> lead trail) "" (from-codepoints (subvec cps lead (inc trail))))))

(defn triml-text
  "Oracle for the kernel's triml-text: strips ASCII whitespace from the left."
  [s]
  (let [cps (vec (codepoints-of s))
        n (count cps)
        lead (loop [i 0] (if (and (< i n) (ascii-ws? (nth cps i))) (recur (inc i)) i))]
    (from-codepoints (subvec cps lead))))

(defn trimr-text
  "Oracle for the kernel's trimr-text: strips ASCII whitespace from the right."
  [s]
  (let [cps (vec (codepoints-of s))
        n (count cps)
        trail (loop [i (dec n)] (if (and (>= i 0) (ascii-ws? (nth cps i))) (recur (dec i)) i))]
    (if (neg? trail) "" (from-codepoints (subvec cps 0 (inc trail))))))

(defn blank-text?
  "Oracle for the kernel's blank-text?: empty or ASCII whitespace only."
  [s]
  (= "" (trim-text s)))

(defn reverse-text
  "Oracle for the kernel's reverse-text: code-point-safe reversal (the kernel
  walks UTF-8 code points; this walks code points too, so astral characters
  survive -- unlike clojure.string/reverse on a surrogate pair). rseq, not
  rseq, not this namespace's own `reverse`, which is
  string-shaped and would see a vector."
  [s]
  (from-codepoints (vec (rseq (vec (codepoints-of s))))))

(defn repeat-text
  "Oracle for the kernel's repeat-text: s repeated times times; zero or
  negative times answer the empty string (clojure.core/repeat is lazy and
  has no negative case)."
  [s times]
  (if (pos? times) (apply str (repeat times s)) ""))

(defn- utf8-byte-offsets
  "Byte offset of each code point index in cps (one past the end for the
  count). The kernel's index answers are UTF-8 byte offsets; this is how the
  oracle converts its code point indexes to the same units."
  [cps]
  (let [width (fn [cp] (cond (< cp 0x80) 1 (< cp 0x800) 2 (< cp 0x10000) 3 :else 4))]
    (loop [i 0 acc 0 offsets [0]]
      (if (= i (count cps))
        offsets
        (recur (inc i) (+ acc (width (nth cps i))) (conj offsets (+ acc (width (nth cps i)))))))))

(defn index-of-text
  "Oracle for the kernel's index-of-text: UTF-8 BYTE offset of the first
  occurrence, or -1. clojure.string/index-of answers a UTF-16 code unit
  index or nil; they agree only on ASCII."
  [s value]
  (let [cps (vec (codepoints-of s))
        needle (vec (codepoints-of value))
        n (count cps)
        m (count needle)
        offsets (utf8-byte-offsets cps)]
    (loop [i 0]
      (cond
        (> (+ i m) n) -1
        (= m 0) (nth offsets i)
        (= (subvec cps i (+ i m)) needle) (nth offsets i)
        :else (recur (inc i))))))

(defn last-index-of-text
  "Oracle for the kernel's last-index-of-text: UTF-8 BYTE offset of the last
  occurrence, or -1 (clojure.string/last-index-of answers a UTF-16 index)."
  [s value]
  (let [cps (vec (codepoints-of s))
        needle (vec (codepoints-of value))
        n (count cps)
        m (count needle)
        offsets (utf8-byte-offsets cps)]
    (loop [i 0 best -1]
      (if (> i (- n m))
        best
        (recur (inc i)
               (if (= (subvec cps i (+ i m)) needle)
                 (nth offsets i)
                 best))))))

(defn pad-left-text
  "Oracle for the kernel's pad-left-text: prepend fill until the string's
  BYTE length reaches width. The kernel measures bytes; this measures code
  points, which agree on ASCII fills -- the divergence the kernel names."
  [s width fill]
  (let [w (count (codepoints-of s))]
    (if (>= w width)
      s
      (if (empty? fill)
        s
        (loop [acc s]
          (if (>= (count (codepoints-of acc)) width)
            acc
            (recur (str fill acc))))))))

(defn pad-right-text
  "Oracle for the kernel's pad-right-text: append fill until width."
  [s width fill]
  (let [w (count (codepoints-of s))]
    (if (>= w width)
      s
      (if (empty? fill)
        s
        (loop [acc s]
          (if (>= (count (codepoints-of acc)) width)
            acc
            (recur (str acc fill))))))))

;; ---------- tranche-2 kernel oracles (2026-09-04) ----------

(defn- byte-index->codepoint-index
  "The kernel's index answers are UTF-8 byte offsets; convert one to a code
  point index for the slicing oracles (nil when the offset is not a boundary
  or is past the end -- the kernel never answers one of those)."
  [cps byte-offset]
  (loop [i 0 acc 0]
    (cond
      (= i (count cps)) (when (= acc byte-offset) i)
      (= acc byte-offset) i
      :else (recur (inc i)
                   (+ acc (let [cp (nth cps i)]
                            (cond (< cp 0x80) 1 (< cp 0x800) 2
                                  (< cp 0x10000) 3 :else 4)))))))

(defn replace-first-text
  "Oracle for the kernel's replace-first-text: replace the FIRST occurrence
  of the literal match. Not regex -- the kernel matches literals, so this
  diverges from clojure.string/replace (which takes a pattern)."
  [s match replacement]
  (let [cps (vec (codepoints-of s))
        mcps (vec (codepoints-of match))
        rcps (vec (codepoints-of replacement))
        n (count cps) m (count mcps)]
    (loop [i 0]
      (cond
        (> (+ i m) n) s
        (= m 0) s
        (= (subvec cps i (+ i m)) mcps)
        (from-codepoints (vec (concat (subvec cps 0 i) rcps (subvec cps (+ i m)))))
        :else (recur (inc i))))))

(defn trim-newline-text
  "Oracle for the kernel's trim-newline-text: remove ONE trailing \\n or
  \\r\\n (clojure.string/trim-newline semantics)."
  [s]
  (if (ends-with? s "\n")
    (if (ends-with? s "\r\n")
      (subs s 0 (- (count s) 2))
      (subs s 0 (- (count s) 1)))
    s))

(defn- split-literal
  "Split s on the LITERAL separator (no regex), portable. The kernel's
  segment face over the same walk its index-of does."
  [s sep]
  (if (empty? sep)
    [s]
    (loop [i 0 start 0 parts []]
      (if (> (+ i (count sep)) (count s))
        (conj parts (subs s start))
        (if (= (subs s i (+ i (count sep))) sep)
          (recur (+ i (count sep)) (+ i (count sep))
                 (conj parts (subs s start i)))
          (recur (inc i) start parts))))))

(defn segment-count-text
  "Oracle for the kernel's segment-count-text: how many separator-delimited
  segments the text has (separators + 1). Matches
  clojure.string/split's count for non-regex separators."
  [s sep]
  (if (empty? sep)
    0
    (count (split-literal s sep))))

(defn segment-text
  "Oracle for the kernel's segment-text: the nth (0-based) separator-delimited
  segment, or nil out of range (the kernel answers \"\" -- the divergence is
  the SENTINEL only; the segment contents agree). Literal separator, no
  regex."
  [s sep n]
  (if (or (empty? sep) (neg? n))
    nil
    (let [parts (split-literal s sep)]
      (when (< n (count parts))
        (nth parts n)))))

(defn pad-center-text
  "Oracle for the kernel's pad-center-text: center by CODE POINT count; the
  odd shortfall's extra unit lands on the RIGHT. The kernel measures BYTES
  and pads by whole FILL strings, so with a multi-byte fill its result can
  overshoot width -- this oracle cannot, which is the divergence the kernel
  names (never split a fill vs never exceed the count)."
  [s width fill]
  (let [w (count (codepoints-of s))]
    (if (>= w width)
      s
      (if (empty? fill)
        s
        (let [short (- width w)
              left (quot short 2)]
          (loop [acc s n left]
            (if (zero? n)
              (loop [a acc m (- short left)]
                (if (zero? m)
                  a
                  (recur (str a fill) (dec m))))
              (recur (str fill acc) (dec n)))))))))

;; ---------- escape (clojure.string/escape) ----------
;;
;; The last clojure.string entry point this namespace did not carry. It is the
;; one every HTML/CSV/shell-quoting call site in this workspace reaches for,
;; and the hand-rolled substitutes for it are usually a chain of `replace`
;; calls -- which is not the same function: a replace-chain re-scans its own
;; output, so escaping `&` to `&amp;` and then `<` to `&lt;` double-escapes any
;; `&` that the second replacement itself introduces. `escape` makes exactly
;; one pass and never looks at what it has already emitted, which is why the
;; order of entries in `cmap` cannot matter.
;;
;; Portability note: this walks UTF-16 code units, like clojure.string/escape.
;; `(seq s)` yields Characters on the JVM and single-character strings on
;; ClojureScript, and a `\<` literal in `cmap` reads as the matching type on
;; each host, so the same `cmap` works on both. A character outside the BMP is
;; two code units on both hosts and so is passed through as its two halves --
;; the same as the JVM original. Do not "fix" that to be codepoint-based here;
;; `codepoints`/`from-codepoints` above are the codepoint-level surface.

(defn escape
  "Return a new string, using `cmap` to escape each character `ch` of `s`:
  if `(cmap ch)` is nil the character is appended unchanged, otherwise the
  replacement (a string or character) is appended in its place. Mirrors
  clojure.string/escape.

  Single-pass: a replacement is never itself re-escaped, so the order of
  entries in `cmap` is irrelevant.

      (escape \"a<b&c\" {\\< \"&lt;\" \\& \"&amp;\"}) => \"a&lt;b&amp;c\""
  [s cmap]
  (let [s (str s)]
    (if (empty? s)
      s
      (apply str (map (fn [ch] (let [r (get cmap ch)] (if (nil? r) ch r))) s)))))

;; ---------- re-quote ----------
;;
;; The seven call sites this was written for all hand-rolled the same thing:
;;
;;     (str/replace needle #"[.*+?^${}()|\[\]\\]" "\\$&")
;;
;; and that expression means two different things on the two hosts. In
;; ClojureScript `clojure.string/replace` hands the template to JS
;; `String.replace`, where `$&` is the whole match, so the result is
;; backslash-then-metacharacter -- correct. On the JVM `$` must be followed by
;; a digit, so the same source is an error; and under `expand-template` above,
;; which deliberately does not implement `$&`, it expands to the two literal
;; characters `$&` -- every escaped character becomes `$&` and the pattern
;; silently stops matching what it was quoting. The `$1` spelling of the same
;; idiom diverges the other way.
;;
;; So this is not a template that needs fixing seven times. It is a function
;; that was missing: `clojure.string` has no regex quoter, and the two hosts'
;; own answers -- `java.util.regex.Pattern/quote` and `goog.string/regExpEscape`
;; -- are not interchangeable. `Pattern/quote` wraps the input in `\Q...\E`,
;; which JavaScript's regex engine does not understand at all, so a pattern
;; built with it on the JVM cannot be shipped to a browser.
;;
;; The escaped set is the intersection that is safe everywhere: each character
;; below is a metacharacter in both engines, and `\<char>` is a legal escape in
;; Java, in JavaScript, and in JavaScript's unicode (`u`) mode, which rejects
;; identity escapes for anything outside this set. `-` is deliberately absent:
;; it is only special inside a character class, and `\-` is NOT legal under
;; `u`, so escaping it would trade a non-problem for a real one.

(def ^:private regex-metacharacter-escapes
  {\\ "\\\\" \^ "\\^" \$ "\\$" \. "\\." \| "\\|" \? "\\?" \* "\\*" \+ "\\+"
   \( "\\(" \) "\\)" \[ "\\[" \] "\\]" \{ "\\{" \} "\\}"})

(defn re-quote
  "Return `s` escaped so that `(re-pattern (re-quote s))` matches `s` literally
  and nothing else, on every host.

  Use this instead of writing an escaping regex by hand: the usual hand-rolled
  form uses a `$&` or `$1` replacement template, and those expand differently
  on the JVM and in ClojureScript.

      (re-quote \"a.b\")                  => \"a\\\\.b\"
      (re-find (re-pattern (re-quote \"a.b\")) \"axb\") => nil

  Unlike `java.util.regex.Pattern/quote` the result is an ordinary pattern
  source with no `\\Q...\\E`, so it can be embedded in a larger pattern and
  read by a JavaScript engine."
  [s]
  (escape (str s) regex-metacharacter-escapes))
