From bec4dc40ed0fe4269537cd787a6655cdf043431b Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 12 Jul 2026 12:35:17 +0300 Subject: [PATCH 1/3] Match completion candidates fuzzily, like compliment Completion was prefix-only, while compliment matches fuzzily: `pr-fn` completes `print-function`, `cs` completes `clojure.string`. Since compliment's core doesn't re-match what a source returns, our prefix filter was the hard ceiling on this. candidate-match? now mirrors compliment's per-kind rules via compliment.utils/fuzzy-matches?: `.`-separated fuzzy for namespaces and classes, `-`-separated fuzzy for everything var-like, and plain prefix matching for keywords (which compliment also keeps prefix-only). --- src/main/suitable/compliment/sources/cljs.clj | 12 +++++- .../suitable/compliment/sources/cljs_test.clj | 40 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/main/suitable/compliment/sources/cljs.clj b/src/main/suitable/compliment/sources/cljs.clj index a93311b..da7551a 100644 --- a/src/main/suitable/compliment/sources/cljs.clj +++ b/src/main/suitable/compliment/sources/cljs.clj @@ -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 diff --git a/src/test/suitable/compliment/sources/cljs_test.clj b/src/test/suitable/compliment/sources/cljs_test.clj index c44d659..2bdc90f 100644 --- a/src/test/suitable/compliment/sources/cljs_test.clj +++ b/src/test/suitable/compliment/sources/cljs_test.clj @@ -172,8 +172,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 (= '() @@ -252,9 +255,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" @@ -267,6 +271,34 @@ {: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 extra-metadata (testing "Extra metadata: namespace :doc" (binding [*extra-metadata* #{:doc}] From a9e5fb78be83fc6ea3a76d8f142b6090610ae25f Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 12 Jul 2026 12:38:16 +0300 Subject: [PATCH 2/3] Rank completion candidates with compliment-style priorities compliment attaches a :priority to each candidate (lower sorts first) and its core sorts the pooled candidates by it; ours had none, so every candidate landed in the same bottom bucket and only sorted by length. Candidates now carry the same priorities compliment uses for Clojure: a var in the current ns first (30), then cljs.core (31), then namespaces (50 for cljs.*/clojure.*, 51 otherwise), then classes (60). Keywords and special forms stay unranked, exactly like compliment. --- src/main/suitable/compliment/sources/cljs.clj | 27 ++++++++++++++++++- .../suitable/compliment/sources/cljs_test.clj | 27 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/suitable/compliment/sources/cljs.clj b/src/main/suitable/compliment/sources/cljs.clj index da7551a..c800614 100644 --- a/src/main/suitable/compliment/sources/cljs.clj +++ b/src/main/suitable/compliment/sources/cljs.clj @@ -282,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 diff --git a/src/test/suitable/compliment/sources/cljs_test.clj b/src/test/suitable/compliment/sources/cljs_test.clj index 2bdc90f..93daf0d 100644 --- a/src/test/suitable/compliment/sources/cljs_test.clj +++ b/src/test/suitable/compliment/sources/cljs_test.clj @@ -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))) @@ -299,6 +306,24 @@ (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}] From 815b1d17e57935a01e71b4d4975b2c76c28977e6 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 12 Jul 2026 12:38:38 +0300 Subject: [PATCH 3/3] Update changelog for fuzzy matching and ranking --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e375e23..a675581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.