diff --git a/CHANGELOG.md b/CHANGELOG.md index 2045f19..1547127 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 703ea3a..6a86762 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 2576132..d828571 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 0000000..1bc92ce --- /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 +;; 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 . + +;;; 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 0000000..16b64de --- /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]))