Stdlib/reader fixes to run markdown-clj

Bringing up yogthos/markdown-clj surfaced a batch of Clojure-conformance gaps:

- clojure.java.io/writer returned nil for a Writer/StringWriter (it only
  handled paths); now passes a Writer or file handle through, like reader does.
- StringWriter had no :close field, so with-open errored closing it.
- java.io.Reader had no .readLine method (only the :read-line-fn used by
  line-seq); markdown's main loop calls .readLine directly.
- Writer.write(int) wrote the int's digits instead of the char for that code.
  StringBuilder.append(int) keeps Java semantics (the digits) — the two differ,
  so the char-code path is local to the writer, not shared render-piece.
- drop-while over a string errored in array/slice; it now char-seqs the string
  like take-while/remove already do.
- re-seq returned an empty seq instead of nil on no match, so
  (if-let [m (re-seq ...)] ...) always took the truthy branch — an infinite loop
  in markdown's thaw-string.
- The #() reader didn't scan % inside map {} or set #{} literals, so
  #(identity {:text %}) compiled as a 0-arg fn.

re-seq-nil and the #() map/set scan are general bugs, not markdown-specific.
This commit is contained in:
Yogthos 2026-06-14 20:58:53 -04:00
parent 288b20956c
commit fc71910fda
5 changed files with 35 additions and 6 deletions

View file

@ -26,7 +26,13 @@
;; java.io.Reader surface (.read/.mark/.reset) plus line-seq's
;; :read-line-fn, which a raw janet file handle has neither of
:else (java.io.StringReader. (janet/slurp (str x)))))
(defn writer [x] (janet.file/open (str x) :w))
(defn writer [x]
(cond
;; already a Writer/StringWriter shim — pass through, like the JVM's
;; io/writer on a Writer (markdown-clj's md-to-html writes into a StringWriter)
(= :jolt/writer (get x :jolt/type)) x
(= :core/file (janet/type x)) x ; already a janet file handle
:else (janet.file/open (str x) :w))) ; a path
(defn input-stream [x] (reader x))
(defn output-stream [x] (writer x))

View file

@ -1237,7 +1237,11 @@
(set cur (ls-rest cur)))
(if (seq-done? cur) nil (realize-ls cur))))
(make-lazy-seq (dwstep coll)))
(let [c (realize-for-iteration coll)]
# A string iterates as a seq of chars in Clojure; realize-for-iteration
# passes strings through, so char-seq it here (as take-while/remove do) —
# otherwise pred sees raw bytes and array/slice rejects the string.
(let [c0 (realize-for-iteration coll)
c (if (string? c0) (map make-char (string/bytes c0)) c0)]
(var start 0)
(while (and (< start (length c)) (pred (c start)))
(++ start))

View file

@ -176,7 +176,9 @@
:buf (cond (nil? init) @"" (number? init) (buffer/new init) (buffer init))})
(defn make-string-writer []
@{:jolt/type :jolt/writer :buf @"" :sink nil})
# :close lets with-open close the writer (core-close-resource calls :close);
# it's a no-op so .toString after with-open still sees the buffer.
@{:jolt/type :jolt/writer :buf @"" :sink nil :close (fn [] nil)})
(defn make-out-writer []
@{:jolt/type :jolt/writer :buf nil :sink prin})
@ -186,6 +188,12 @@
(and (struct? x) (= :jolt/char (get x :jolt/type))) (string/from-bytes (x :ch))
(string x)))
# Writer.write(int) writes the CHAR for that code (unlike StringBuilder.append(int),
# which appends the int's digits). jolt chars are bytes, so this round-trips UTF-8
# byte-for-byte with readLine.
(defn- writer-piece [x]
(if (number? x) (string/from-bytes (math/trunc x)) (render-piece x)))
# Read one unit from any reader-ish value: our shims dispatch through their
# tagged "read"; a janet core/file reads one byte. -1 at EOF.
(defn- read-unit [r]
@ -221,6 +229,7 @@
(let [b ((self :s) (self :pos))]
(put self :pos (+ 1 (self :pos)))
b)))
"readLine" (fn [self] ((self :read-line-fn)))
"mark" (fn [self &opt limit] (put self :marked (self :pos)) nil)
"reset" (fn [self] (put self :pos (or (self :marked) 0)) nil)
"skip" (fn [self n] (put self :pos (min (length (self :s)) (+ (self :pos) n))) n)
@ -232,8 +241,8 @@
(register-tagged-methods! :jolt/writer
@{"write" (fn [self x]
(if (self :sink)
((self :sink) (render-piece x))
(buffer/push-string (self :buf) (render-piece x)))
((self :sink) (writer-piece x))
(buffer/push-string (self :buf) (writer-piece x)))
nil)
"append" (fn [self x]
(if (self :sink)

View file

@ -456,6 +456,10 @@
(cond (= i :rest) (set has-rest true)
(and i (> i max-n)) (set max-n i)))
(or (array? f) (tuple? f)) (each x f (scan-pct x))
# set literal form — scan its elements (#(... #{%} ...))
(and (struct? f) (= :jolt/set (f :jolt/type))) (each x (f :value) (scan-pct x))
# map literal form — scan its keys AND values (#(... {:k %} ...))
(not (nil? (form-kv-order f))) (each x (form-kv-order f) (scan-pct x))
nil))
(scan-pct form)
# One canonical gensym per slot 1..max-n (placeholders for unused), plus rest.
@ -473,6 +477,10 @@
f))
(array? f) (array ;(map replace-pct f))
(tuple? f) (tuple ;(map replace-pct f))
(and (struct? f) (= :jolt/set (f :jolt/type)))
{:jolt/type :jolt/set :value (tuple ;(map replace-pct (f :value)))}
(not (nil? (form-kv-order f)))
(reader-map (array ;(map replace-pct (form-kv-order f))))
f))
(def replaced (replace-pct form))
(def arg-names @[])

View file

@ -397,7 +397,9 @@
(array/push out (groups->result g (re :ngroups)))
(set pos (+ pos (max 1 (length whole)))))
(++ pos)))
out)
# Clojure's re-seq is nil (not an empty seq) when there are no matches, so
# `(if-let [m (re-seq ...)] ...)` works — an empty seq would be truthy.
(if (= 0 (length out)) nil out))
(defn re-split [re s]
(def re (re-pattern re))