branch: elpa/clojure-ts-mode
commit 95aed261172c2d8133dd4c87c2d5cfcf2ea30476
Author: Roman Rudakov <[email protected]>
Commit: Bozhidar Batsov <[email protected]>

    [#107] Introduce clojure-ts-completion-at-point-function
---
 CHANGELOG.md                       |   3 +-
 README.md                          |  20 +++--
 clojure-ts-mode.el                 | 169 ++++++++++++++++++++++++++++++++-----
 test/clojure-ts-mode-completion.el | 153 +++++++++++++++++++++++++++++++++
 test/samples/completion.clj        |  56 ++++++++++++
 5 files changed, 376 insertions(+), 25 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2045f19363..15471275d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,8 +15,9 @@
   highlighting C++ syntax in Jank `native/raw` forms.
 - [#103](https://github.com/clojure-emacs/clojure-ts-mode/issues/103): 
Introduce `clojure-ts-clojurescript-use-js-parser` customization which
   allows highlighting JS syntax in ClojureScript `js*` forms.
-- Introduce the `clojure-ts-extra-def-forms` customization option to specify
+- [#104](https://github.com/clojure-emacs/clojure-ts-mode/pull/104): Introduce 
the `clojure-ts-extra-def-forms` customization option to specify
   additional `defn`-like forms that should be fontified.
+- Introduce completion feature and `clojure-ts-completion-enabled` 
customization.
 
 ## 0.4.0 (2025-05-15)
 
diff --git a/README.md b/README.md
index 703ea3a1f0..6a86762dd9 100644
--- a/README.md
+++ b/README.md
@@ -539,6 +539,21 @@ multi-arity function or macro. Function can be defined 
using `defn`, `fn` or
 By default prefix for all refactoring commands is `C-c C-r`. It can be changed
 by customizing `clojure-ts-refactor-map-prefix` variable.
 
+## Code completion
+
+`clojure-ts-mode` provides basic code completion functionality.  Completion 
only
+works for the current source buffer and includes completion of top-level
+definitions and local bindings.  This feature can be turned off by setting:
+
+```emacs-lisp
+(setopt clojure-ts-completion-enabled nil)
+```
+
+Here's the short video illustrating the feature with built-in completion (it
+should also work well with more advanced packages like company and corfu):
+
+https://github.com/user-attachments/assets/7c37179f-5a5d-424f-9bd6-9c8525f6b2f7
+
 ## Migrating to clojure-ts-mode
 
 If you are migrating to `clojure-ts-mode` note that `clojure-mode` is still
@@ -576,11 +591,6 @@ and `clojure-mode` (this is very helpful when dealing with 
`derived-mode-p` chec
 - Navigation by sexp/lists might work differently on Emacs versions lower
   than 31. Starting with version 31, Emacs uses Tree-sitter 'things' settings, 
if
   available, to rebind some commands.
-- The indentation of list elements with metadata is inconsistent with other
-  collections. This inconsistency stems from the grammar's interpretation of
-  nearly every definition or function call as a list. Therefore, modifying the
-  indentation for list elements would adversely affect the indentation of
-  numerous other forms.
 
 ## Frequently Asked Questions
 
diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el
index 2576132311..d828571775 100644
--- a/clojure-ts-mode.el
+++ b/clojure-ts-mode.el
@@ -266,6 +266,12 @@ values like this:
   :safe #'listp
   :type '(repeat string))
 
+(defcustom clojure-ts-completion-enabled t
+  "Enable built-in completion feature."
+  :package-version '(clojure-ts-mode . "0.5")
+  :safe #'booleanp
+  :type 'boolean)
+
 (defvar clojure-ts-mode-remappings
   '((clojure-mode . clojure-ts-mode)
     (clojurescript-mode . clojure-ts-clojurescript-mode)
@@ -1561,26 +1567,28 @@ function literal."
     "map_lit" "ns_map_lit" "vec_lit" "set_lit")
   "A regular expression that matches nodes that can be treated as lists.")
 
+(defconst clojure-ts--defun-symbols-regex
+  (rx bol
+      (or "def"
+          "defn"
+          "defn-"
+          "definline"
+          "defrecord"
+          "defmacro"
+          "defmulti"
+          "defonce"
+          "defprotocol"
+          "deftest"
+          "deftest-"
+          "ns"
+          "definterface"
+          "deftype"
+          "defstruct")
+      eol))
+
 (defun clojure-ts--defun-node-p (node)
   "Return TRUE if NODE is a function or a var definition."
-  (clojure-ts--list-node-sym-match-p node
-                                     (rx bol
-                                         (or "def"
-                                             "defn"
-                                             "defn-"
-                                             "definline"
-                                             "defrecord"
-                                             "defmacro"
-                                             "defmulti"
-                                             "defonce"
-                                             "defprotocol"
-                                             "deftest"
-                                             "deftest-"
-                                             "ns"
-                                             "definterface"
-                                             "deftype"
-                                             "defstruct")
-                                         eol)))
+  (clojure-ts--list-node-sym-match-p node clojure-ts--defun-symbols-regex))
 
 (defconst clojure-ts--markdown-inline-sexp-nodes
   '("inline_link" "full_reference_link" "collapsed_reference_link"
@@ -2512,6 +2520,126 @@ before DELIM-OPEN."
     map)
   "Keymap for `clojure-ts-mode'.")
 
+;;; Completion
+
+(defconst clojure-ts--completion-query-defuns
+  (treesit-query-compile 'clojure
+                         `((source
+                            (list_lit
+                             ((sym_lit) @sym
+                              (:match ,clojure-ts--defun-symbols-regex @sym))
+                             :anchor [(comment) (meta_lit) (old_meta_lit)] :*
+                             :anchor ((sym_lit) @defun-candidate)))))
+  "Query that matches top-level definitions.")
+
+(defconst clojure-ts--completion-defn-with-args-sym-regex
+  (rx bol
+      (or "defn"
+          "defn-"
+          "fn"
+          "fn*"
+          "defmacro"
+          "defmethod")
+      eol)
+  "Regexp that matches a symbol of definition with arguments vector.")
+
+(defconst clojure-ts--completion-let-like-sym-regex
+  (rx bol
+      (or "let"
+          "if-let"
+          "when-let"
+          "if-some"
+          "when-some"
+          "loop"
+          "with-open"
+          "dotimes"
+          "with-local-vars")
+      eol)
+  "Regexp that matches a symbol of let-like form.")
+
+(defconst clojure-ts--completion-locals-query
+  (treesit-query-compile 'clojure `((vec_lit (sym_lit) @local-candidate)
+                                    (map_lit (sym_lit) @local-candidate)))
+  "Query that matches a local binding symbol.
+
+Symbold must be a direct child of a vector or a map.  This query covers
+bindings vector as well as destructuring syntax.")
+
+(defconst clojure-ts--completion-annotations
+  (list 'defun-candidate " Definition"
+        'local-candidate " Local variable")
+  "Property list of completion candidate type and annotation string.")
+
+(defun clojure-ts--completion-annotation-function (candidate)
+  "Return annotation for a completion CANDIDATE."
+  (thread-last minibuffer-completion-table
+               (alist-get candidate)
+               (plist-get clojure-ts--completion-annotations)))
+
+(defun clojure-ts--completion-defun-with-args-node-p (node)
+  "Return non-nil if NODE is a function definition with arguments."
+  (when-let* ((sym-name (clojure-ts--list-node-sym-text node)))
+    (string-match-p clojure-ts--completion-defn-with-args-sym-regex sym-name)))
+
+(defun clojure-ts--completion-fn-args-nodes ()
+  "Return a list of captured nodes that represent function arguments.
+
+The function traverses the syntax tree upwards and returns nodes from
+all functions along the way."
+  (let ((parent-defun (clojure-ts--parent-until 
#'clojure-ts--completion-defun-with-args-node-p))
+        (captured-nodes))
+    (while parent-defun
+      (when-let* ((args-vec (clojure-ts--node-child parent-defun "vec_lit")))
+        (setq captured-nodes
+              (append captured-nodes
+                      (treesit-query-capture args-vec 
clojure-ts--completion-locals-query))
+              parent-defun (treesit-parent-until parent-defun
+                                                 
#'clojure-ts--completion-defun-with-args-node-p))))
+    captured-nodes))
+
+(defun clojure-ts--completion-let-like-node-p (node)
+  "Return non-nil if NODE is a let-like form."
+  (when-let* ((sym-name (clojure-ts--list-node-sym-text node)))
+    (string-match-p clojure-ts--completion-let-like-sym-regex sym-name)))
+
+(defun clojure-ts--completion-let-locals-nodes ()
+  "Return a list of captured nodes that represent bindings in let forms.
+
+The function tranverses the syntax tree upwards and returns nodes from
+all let bindings found along the way."
+  (let ((parent-let (clojure-ts--parent-until 
#'clojure-ts--completion-let-like-node-p))
+        (captured-nodes))
+    (while parent-let
+      (when-let* ((bindings-vec (clojure-ts--node-child parent-let "vec_lit")))
+        (setq captured-nodes
+              (append captured-nodes
+                      (treesit-query-capture bindings-vec 
clojure-ts--completion-locals-query))
+              parent-let (treesit-parent-until parent-let
+                                               
#'clojure-ts--completion-let-like-node-p))))
+    captured-nodes))
+
+(defun clojure-ts-completion-at-point-function ()
+  "Return a completion table for the symbol around point."
+  (when-let* ((bounds (bounds-of-thing-at-point 'symbol))
+              (source (treesit-buffer-root-node 'clojure))
+              (nodes (append (treesit-query-capture source 
clojure-ts--completion-query-defuns)
+                             (clojure-ts--completion-fn-args-nodes)
+                             (clojure-ts--completion-let-locals-nodes))))
+    (list (car bounds)
+          (cdr bounds)
+          (thread-last nodes
+                       ;; Remove node at point
+                       (seq-remove (lambda (item) (= (treesit-node-end (cdr 
item)) (point))))
+                       ;; Remove unwanted captured nodes
+                       (seq-filter (lambda (item)
+                                     (not (member (car item) '(sym kwd)))))
+                       ;; Produce alist of candidates
+                       (seq-map (lambda (item) (cons (treesit-node-text (cdr 
item) t) (car item))))
+                       ;; Remove duplicated candidates
+                       (seq-uniq))
+          :exclusive 'no
+          :annotation-function #'clojure-ts--completion-annotation-function)))
+
 (defvar clojure-ts-clojurescript-mode-map
   (let ((map (make-sparse-keymap)))
     (set-keymap-parent map clojure-ts-mode-map)
@@ -2670,7 +2798,10 @@ REGEX-AVAILABLE."
               clojure-ts--imenu-settings)
 
   (when (boundp 'treesit-thing-settings) ;; Emacs 30+
-    (setq-local treesit-thing-settings clojure-ts--thing-settings)))
+    (setq-local treesit-thing-settings clojure-ts--thing-settings))
+
+  (when clojure-ts-completion-enabled
+    (add-to-list 'completion-at-point-functions 
#'clojure-ts-completion-at-point-function)))
 
 ;;;###autoload
 (define-derived-mode clojure-ts-mode prog-mode "Clojure[TS]"
diff --git a/test/clojure-ts-mode-completion.el 
b/test/clojure-ts-mode-completion.el
new file mode 100644
index 0000000000..1bc92cec10
--- /dev/null
+++ b/test/clojure-ts-mode-completion.el
@@ -0,0 +1,153 @@
+;;; clojure-ts-mode-completion.el --- clojure-ts-mode: completion tests  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2025  Roman Rudakov
+
+;; Author: Roman Rudakov <[email protected]>
+;; Keywords:
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Completion is a unique `clojure-ts-mode' feature.
+
+;;; Code:
+
+(require 'clojure-ts-mode)
+(require 'buttercup)
+(require 'test-helper "test/test-helper")
+
+(describe "clojure-ts-complete-at-point-function"
+  ;; NOTE: This function returns unfiltered candidates, so prefix doesn't 
really
+  ;; matter here.
+
+  (it "should complete global vars"
+    (with-clojure-ts-buffer-point "
+(def foo :first)
+
+(def bar :second)
+
+(defn baz
+  []
+  (println foo bar))
+
+b|"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("foo" . defun-candidate)
+                          ("bar" . defun-candidate)
+                          ("baz" . defun-candidate)))))
+
+  (it "should complete function arguments"
+    (with-clojure-ts-buffer-point "
+(def foo :first)
+
+(def bar :second)
+
+(defn baz
+  [username]
+  (println u|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("foo" . defun-candidate)
+                          ("bar" . defun-candidate)
+                          ("baz" . defun-candidate)
+                          ("username" . local-candidate)))))
+
+  (it "should not complete function arguments outside of function"
+    (with-clojure-ts-buffer-point "
+(def foo :first)
+
+(def bar :second)
+
+(defn baz
+  [username]
+  (println bar))
+
+u|"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("foo" . defun-candidate)
+                          ("bar" . defun-candidate)
+                          ("baz" . defun-candidate)))))
+
+  (it "should complete destructured function arguments"
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [{:keys [username]}]
+  (println u|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("username" . local-candidate))))
+
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [{:strs [username]}]
+  (println u|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("username" . local-candidate))))
+
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [{:syms [username]}]
+  (println u|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("username" . local-candidate))))
+
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [{username :name}]
+  (println u|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("username" . local-candidate))))
+
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [[first-name last-name]]
+  (println f|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("first-name" . local-candidate)
+                          ("last-name" . local-candidate)))))
+
+  (it "should complete vector bindings"
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [first-name]
+  (let [last-name \"Doe\"
+        address {:street \"Whatever\" :zip-code 2222}
+        {:keys [street zip-code]} address]
+    a|))"
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("first-name" . local-candidate)
+                          ("last-name" . local-candidate)
+                          ("address" . local-candidate)
+                          ("street" . local-candidate)
+                          ("zip-code" . local-candidate)))))
+
+  (it "should not complete called function names"
+    (with-clojure-ts-buffer-point "
+(defn baz
+  [first-name]
+  (let [full-name (str first-name \"Doe\")]
+    s|))"
+      ;; `str' should not be among the candidates.
+      (expect (nth 2 (clojure-ts-completion-at-point-function))
+              :to-equal '(("baz" . defun-candidate)
+                          ("first-name" . local-candidate)
+                          ("full-name" . local-candidate))))))
+
+(provide 'clojure-ts-mode-completion)
+;;; clojure-ts-mode-completion.el ends here
diff --git a/test/samples/completion.clj b/test/samples/completion.clj
new file mode 100644
index 0000000000..16b64de570
--- /dev/null
+++ b/test/samples/completion.clj
@@ -0,0 +1,56 @@
+(ns completion)
+
+(def my-var "Hello")
+(def my-another-var "World")
+
+(defn- my-function
+  "This is a docstring."
+  [some-arg]
+  (let [to-print (str "Hello" some-arg)]
+    (println my-var my-another-var to-print)))
+
+(fn [anon-arg]
+  anon-arg)
+
+(def hello-string "Hello")
+
+(defn complete-example
+  "Docstring won't interfere with completion."
+  [arg1 arg2 & {:keys [destructured]}]
+  ;; Here only function args and globals should be completed.
+  (println arg1 arg2 destructured)
+  (let [foo                       "bar" ; comment
+        baz                       ^String hello
+        map-var                   {:users/usename "Roma"}
+        {:users/keys [username]}  map-var
+        another-map               {:address "Universe"}
+        {custom-address :address} another-map
+        bar                       :kwd]
+    ;; Here let bindings are available in addition to globals and function 
args.
+    (println arg1 foo map-var custom-address username)
+    (when-let [nested-var "Whatever"]
+      (with-open [output-stream (io/output-stream "some-file")]
+        (println foo
+                 baz
+                 hello
+                 map-var
+                 username
+                 another-map
+                 custom-address
+                 bar)
+        ;; Here we should see everything
+        (output-stream nested-var output-stream another-map)))
+    ;; And here only let bindings, globals and function args again.
+    (println username)))
+
+(def vec-variable ["one" "two" "three"])
+
+(let [[one two three] vec-variable]
+  (println one two three))
+
+(defn nested-fn
+  [top-arg]
+  (filter (fn [item]
+            ;; Both arguments are available here.
+            (= item top-arg))
+          [1 2 3 4 5]))

Reply via email to