Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## master

- Match static completion candidates fuzzily, like compliment does for Clojure (`pr-fn` completes `print-function`, `cs` completes `clojure.string`).
- Rank static completion candidates with compliment-style priorities (current-ns vars first, then `cljs.core`, then namespaces and classes).

## 0.7.0 (2026-07-12)

- Adapt to piggieback 0.7.0's delegating repl-env so Node.js dynamic completion keeps working.
Expand Down
39 changes: 36 additions & 3 deletions src/main/suitable/compliment/sources/cljs.clj
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,16 @@
(map first (vals (group-by :candidate candidates))))

(defn- candidate-match?
[candidate prefix]
(.startsWith ^String (:candidate candidate) prefix))
"Tests whether a candidate matches the prefix. Mirrors compliment's per-kind
matching: fuzzy with `.` separators for namespaces and classes, fuzzy with `-`
separators for everything var-like (functions, macros, special forms, ...),
and plain prefix matching for keywords."
[candidate ^String prefix]
(let [^String cand (:candidate candidate)]
(case (:type candidate)
(:namespace :class) (utils/fuzzy-matches? prefix cand \.)
:keyword (.startsWith cand prefix)
(utils/fuzzy-matches? prefix cand \-))))

(defn plain-symbol?
"Tests if prefix is a symbol with no / (qualified), : (keyword) and
Expand All @@ -274,12 +282,37 @@
[x]
(re-matches #"[^\/\:\.][^\/\:]+" x))

(def ^:private base-priority
"Base `:priority` per candidate type (lower sorts first), mirroring
compliment's ordering. Keywords and special forms get no priority, so they
sink to the bottom just like in compliment."
{:function 32, :macro 32, :var 32, :protocol 32, :protocol-function 32
:record 32, :type 32
:namespace 51
:class 60})

(defn- with-priority
"Attaches a compliment-style `:priority` to a candidate, relative to the ns the
completion was requested from: vars in the current ns rank first, then
`cljs.core`, and `cljs.*`/`clojure.*` namespaces rank above other namespaces."
[context-ns {:keys [type ns candidate] :as c}]
(if-let [base (base-priority type)]
(assoc c :priority
(cond
(and ns (= ns (str context-ns))) 30
(= ns "cljs.core") 31
(and (= type :namespace)
(re-find #"^(?:cljs|clojure)\." candidate)) 50
:else base))
c))

(def ^:dynamic *compiler-env* nil)

(defn- candidates* [prefix context-ns]
(->> (potential-candidates *compiler-env* context-ns prefix *extra-metadata*)
(distinct-candidates)
(filter #(candidate-match? % prefix))))
(filter #(candidate-match? % prefix))
(map #(with-priority context-ns %))))

(defn candidates
"Returns a sequence of candidate data for completions matching the given
Expand Down
67 changes: 62 additions & 5 deletions src/test/suitable/compliment/sources/cljs_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
(binding [cljs-sources/*compiler-env* (cljs-env/create-test-env)]
(f))))

(defn completions
(defn completions*
"Raw candidates, including :priority."
[prefix & [ns]]
(cljs-sources/candidates prefix (some-> ns find-ns) nil))

(defn completions
"Candidates with :priority stripped, so the bulk of the tests can focus on
:candidate/:type/:ns. Ranking is covered separately in the `ranking` deftest."
[prefix & [ns]]
(map #(dissoc % :priority) (completions* prefix ns)))

(defn set=
[coll1 coll2 & colls]
(apply = (set coll1) (set coll2) (map set colls)))
Expand Down Expand Up @@ -172,8 +179,11 @@
(testing "Excluded cljs.core macro"
(is (= '()
(completions "whil" 'suitable.test-ns)))
;; `cljs.core/whil` also fuzzily matches take-while/drop-while (functions),
;; so filter to macros to keep the exclusion assertion focused
(is (= '({:candidate "cljs.core/while" :ns "cljs.core" :type :macro})
(completions "cljs.core/whil" 'suitable.test-ns))))
(->> (completions "cljs.core/whil" 'suitable.test-ns)
(filter #(= :macro (:type %)))))))

(testing "Namespace-qualified macro"
(is (= '()
Expand Down Expand Up @@ -252,9 +262,10 @@
(completions "II"))))

(testing "Protocol fn"
(is (set= '({:candidate "-with-meta" :ns "cljs.core" :type :protocol-function}
{:candidate "-write" :ns "cljs.core" :type :protocol-function})
(completions "-w")))))
;; a bare `-w` fuzzily matches every protocol fn with a `-w` segment, so use
;; a specific prefix here (fuzzy breadth is covered in `fuzzy-matching`)
(is (= '({:candidate "-with-meta" :ns "cljs.core" :type :protocol-function})
(completions "-with-me")))))

(deftest record-completions
(testing "Record"
Expand All @@ -267,6 +278,52 @@
{:candidate "ES6IteratorSeq" :ns "cljs.core" :type :type})
(completions "ES6I")))))

(deftest fuzzy-matching
(testing "vars match fuzzily with `-` as separator"
(is (set= '({:candidate "unchecked-add" :ns "cljs.core" :type :function}
{:candidate "unchecked-add-int" :ns "cljs.core" :type :function})
(completions "u-add")))
;; the separator in the prefix can be skipped in the candidate
(is (set= '({:candidate "cond->" :ns "cljs.core" :type :macro}
{:candidate "cond->>" :ns "cljs.core" :type :macro})
(->> (completions "cond>" 'suitable.test-ns)
(filter #(= :macro (:type %))))))
;; a single fuzzy prefix can span both a function and a macro
(is (set= '({:candidate "make-array" :ns "cljs.core" :type :function}
{:candidate "my-add" :ns "suitable.test-macros" :type :macro})
(completions "m-a" 'suitable.test-ns))))

(testing "namespaces match fuzzily with `.` as separator"
(is (set= '({:candidate "suitable.test-ns" :type :namespace}
{:candidate "suitable.test-ns-dep" :type :namespace})
(completions "su.te"))))

(testing "matching only resumes after a separator, like compliment"
;; there is no `-` in `IIndexed` for the prefix to resume at, so `IIdx`
;; does not fuzzily match it (this mirrors compliment's segment semantics)
(is (= '() (completions "IIdx"))))

(testing "keywords stay prefix-only (no fuzzy), like compliment"
(is (= '() (completions ":rm")))))

(deftest ranking
(letfn [(priority-of [pred cands] (:priority (first (filter pred cands))))]
(testing "a var in the current ns ranks first (priority 30)"
(is (= 30 (:priority (first (completions* "test-pu" 'suitable.test-ns))))))

(testing "a cljs.core var ranks just below (priority 31)"
(is (= 31 (priority-of #(= "map" (:candidate %)) (completions* "map")))))

(testing "namespaces rank below vars (priority 51)"
(is (= 51 (:priority (first (completions* "su.te"))))))

(testing "cljs.* / clojure.* namespaces rank above other namespaces (priority 50)"
(is (= 50 (priority-of #(= "cljs.core" (:candidate %)) (completions* "cljs.co")))))

(testing "keywords and special forms carry no priority (sink to the bottom)"
(is (nil? (:priority (first (completions* ":on" 'suitable.test-ns)))))
(is (nil? (:priority (first (completions* "thr"))))))))

(deftest extra-metadata
(testing "Extra metadata: namespace :doc"
(binding [*extra-metadata* #{:doc}]
Expand Down
Loading