Skip to content

Add some ns manipulation functions from clojure-mode #118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 8, 2025
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [#116](https://github.com/clojure-emacs/clojure-ts-mode/pull/116): Extend built-in completion to complete all imported symbols from an `ns`
form.
- Add documentation and bug reporting commands from `clojure-mode`.
- Add some ns manipulation functions from `clojure-mode`.

## 0.5.1 (2025-06-17)

Expand Down
211 changes: 189 additions & 22 deletions clojure-ts-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
(require 'treesit)
(require 'align)
(require 'subr-x)
(require 'project)

(declare-function treesit-parser-create "treesit.c")
(declare-function treesit-node-eq "treesit.c")
Expand Down Expand Up @@ -266,6 +267,52 @@ values like this:
:safe #'booleanp
:type 'boolean)

(defcustom clojure-ts-build-tool-files
'("project.clj" ; Leiningen
"build.boot" ; Boot
"build.gradle" ; Gradle
"build.gradle.kts" ; Gradle
"deps.edn" ; Clojure CLI (a.k.a. tools.deps)
"shadow-cljs.edn" ; shadow-cljs
"bb.edn" ; babashka
"nbb.edn" ; nbb
"basilisp.edn" ; Basilisp (Python)
)
"A list of files, which identify a Clojure project's root."
:type '(repeat string)
:package-version '(clojure-ts-mode . "0.6.0")
:safe (lambda (value)
(and (listp value)
(cl-every 'stringp value))))

(defcustom clojure-ts-cache-project-dir t
"Whether to cache the results of `clojure-ts-project-dir'."
:type 'boolean
:safe #'booleanp
:package-version '(clojure-ts-mode . "0.6.0"))

(defcustom clojure-ts-cache-ns nil
"Whether to cache the results of `clojure-ts-find-ns'.

Note that this won't work well in buffers with multiple namespace
declarations (which rarely occur in practice) and you'll have to
invalidate this manually after changing the ns for a buffer. If you
update the ns using `clojure-ts-update-ns' the cached value will be
updated automatically."
:type 'boolean
:safe #'booleanp
:package-version '(clojure-ts-mode . "0.6.0"))

(defcustom clojure-ts-directory-prefixes
'("^\\(?:[^/]+/\\)*clj[csxd]*/")
"A list of directory prefixes used by `clojure-expected-ns'.
The prefixes are used to generate the correct namespace."
:type '(repeat string)
:package-version '(clojure-mode . "0.6.0")
:safe (lambda (value)
(and (listp value)
(cl-every 'stringp value))))

(defvar clojure-ts-mode-remappings
'((clojure-mode . clojure-ts-mode)
(clojurescript-mode . clojure-ts-clojurescript-mode)
Expand Down Expand Up @@ -2689,6 +2736,146 @@ The command will prompt you to select one of the available sections."
map)
"Keymap for `clojure-ts-mode'.")

;;; Project helpers

(defun clojure-ts-project-root-path (&optional dir-name)
"Return the absolute path to the project's root directory.

Uses `default-directory' if DIR-NAME is nil. Return nil if not inside
of a project.

NOTE: this function uses `project.el' internally, so if Clojure source
is located in a non-Clojure project, but still under version control,
the root of the project will be returned."
(let ((default-directory (or dir-name default-directory))
(project-vc-extra-root-markers clojure-ts-build-tool-files))
(expand-file-name (project-root (project-current)))))

(defcustom clojure-ts-project-root-function #'clojure-ts-project-root-path
"Function to locate Clojure project root directory."
:type 'function
:risky t
:package-version '(clojure-ts-mode . "0.6.0"))

(defvar-local clojure-ts-cached-project-dir nil
"A project dir cache used to speed up related operations.")

(defun clojure-ts-project-dir (&optional dir-name)
"Return an absolute path to the project's root directory.

Call is delegated down to `clojure-ts-project-root-function' with
optional DIR-NAME as argument.

When `clojure-ts-cache-project-dir' is non-nil, the result of the
command is cached in a buffer local variable
`clojure-ts-cached-project-dir'."
(let ((project-dir (or clojure-ts-cached-project-dir
(funcall clojure-ts-project-root-function dir-name))))
(when (and clojure-ts-cache-project-dir
(derived-mode-p 'clojure-ts-mode)
(not clojure-ts-cached-project-dir))
(setq-local clojure-ts-cached-project-dir project-dir))
project-dir))

(defun clojure-ts-project-relative-path (path)
"Denormalize PATH by making it relative to the project root."
(file-relative-name path (clojure-ts-project-dir)))

;;; ns manipulation

(defun clojure-ts-expected-ns (&optional path)
"Return the namespace matching PATH.

PATH is expected to be an absolute file path.

If PATH is nil, use the path to the file backing the current buffer."
(when-let* ((path (or path (when-let* ((buf-file-name (buffer-file-name)))
(file-truename buf-file-name))))
(relative (clojure-ts-project-relative-path path))
;; Drop prefix from ns for projects with structure
;; src/{clj,cljs,cljc}
(without-prefix (seq-reduce (lambda (acc regex)
(replace-regexp-in-string regex "" acc))
clojure-ts-directory-prefixes
relative)))
(thread-last without-prefix
(file-name-sans-extension)
(string-replace "_" "-")
(string-replace "/" "."))))

(defvar-local clojure-ts-expected-ns-function nil
"The function used to determine the expected namespace of a file.

`clojure-ts-mode' ships a basic function named `clojure-ts-expected-ns'
that does basic heuristics to figure this out. It can be redefined by
other packages to provide a more complex version.")

(defun clojure-ts-insert-ns-form-at-point ()
"Insert a namespace form at point."
(interactive)
(insert (format "(ns %s)" (funcall clojure-ts-expected-ns-function))))

(defun clojure-ts-insert-ns-form ()
"Insert a namespace form at the beginning of the buffer."
(interactive)
(widen)
(goto-char (point-min))
(clojure-ts-insert-ns-form-at-point))

(defvar-local clojure-ts-cached-ns nil
"A buffer ns cache to speed up ns-related operations.")

(defconst clojure-ts--find-ns-query
(treesit-query-compile
'clojure
'(((source (list_lit
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @ns)
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @ns-name)))
(:equal @ns "ns"))
((source (list_lit
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @in-ns)
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (quoting_lit
:anchor (sym_lit name: (sym_name) @ns-name))))
(:equal @in-ns "in-ns"))))
"Compiled Tree-sitter query to capture Clojure ns node.")

(defun clojure-ts-find-ns ()
"Return the name of the current namespace."
(if (and clojure-ts-cache-ns clojure-ts-cached-ns)
clojure-ts-cached-ns
(when-let* ((nodes (treesit-query-capture 'clojure clojure-ts--find-ns-query))
(ns-name-node (cdr (assoc 'ns-name nodes)))
(ns-name (treesit-node-text ns-name-node t)))
(when clojure-ts-cache-ns
(setq-local clojure-ts-cached-ns ns-name))
;; Set the match data, so the namespace could be easily replaced.
(let ((start (treesit-node-start ns-name-node))
(end (treesit-node-end ns-name-node)))
(set-match-data (list start end)))
ns-name)))

(defun clojure-ts-update-ns ()
"Update the namespace of the current buffer.

Useful if a file has been renamed."
(interactive)
(when-let* ((ns-name (funcall clojure-ts-expected-ns-function)))
(save-excursion
(save-match-data
(if (clojure-ts-find-ns)
(progn
;; This relies on the match data, set by `clojure-ts-find-ns'
;; function.
(replace-match ns-name nil nil nil 0)
(message "ns form updated to `%s'" ns-name)
(when clojure-ts-cache-ns
(setq-local clojure-ts-cached-ns ns-name)))
(user-error "Can't find ns form"))))))

;;; Completion

(defconst clojure-ts--completion-query-defuns
Expand Down Expand Up @@ -2978,6 +3165,8 @@ REGEX-AVAILABLE."
outline-search-function #'treesit-outline-search
outline-level #'clojure-ts--outline-level))

(setq-local clojure-ts-expected-ns-function #'clojure-ts-expected-ns)

(setq-local treesit-font-lock-settings
(clojure-ts--font-lock-settings markdown-available regex-available))
(setq-local treesit-font-lock-feature-list
Expand Down Expand Up @@ -3169,28 +3358,6 @@ Useful if you want to switch to the `clojure-mode's mode mappings."
(add-to-list 'interpreter-mode-alist '("nbb" . clojure-ts-clojurescript-mode))))
(message "Clojure TS Mode will not be activated as Tree-sitter support is missing."))

(defvar clojure-ts--find-ns-query
(treesit-query-compile
'clojure
'(((source (list_lit
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @ns)
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @ns-name)))
(:equal @ns "ns"))
((source (list_lit
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (sym_lit name: (sym_name) @in-ns)
:anchor [(comment) (meta_lit) (old_meta_lit)] :*
:anchor (quoting_lit
:anchor (sym_lit name: (sym_name) @ns-name))))
(:equal @in-ns "in-ns")))))

(defun clojure-ts-find-ns ()
"Return the name of the current namespace."
(let ((nodes (treesit-query-capture 'clojure clojure-ts--find-ns-query)))
(treesit-node-text (cdr (assoc 'ns-name nodes)) t)))

(provide 'clojure-ts-mode)

;;; clojure-ts-mode.el ends here
42 changes: 42 additions & 0 deletions test/clojure-ts-mode-util-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,55 @@

;; The unit test suite of Clojure TS Mode

;;; Code:

(require 'clojure-ts-mode)
(require 'buttercup)
(require 'test-helper "test/test-helper")

(describe "clojure-ts-mode-version"
(it "should not be nil"
(expect clojure-ts-mode-version)))

(defvar clojure-ts-cache-project)

(let ((project-dir "/home/user/projects/my-project/")
(clj-file-path "/home/user/projects/my-project/src/clj/my_project/my_ns/my_file.clj")
(project-relative-clj-file-path "src/clj/my_project/my_ns/my_file.clj")
(clj-file-ns "my-project.my-ns.my-file")
(clojure-ts-cache-project nil))

(describe "clojure-ts-project-root-path"
(it "nbb subdir"
(with-temp-dir temp-dir
(let* ((bb-edn (expand-file-name "nbb.edn" temp-dir))
(bb-edn-src (expand-file-name "src" temp-dir)))
(write-region "{}" nil bb-edn)
(make-directory bb-edn-src)
(expect (expand-file-name (clojure-ts-project-dir bb-edn-src))
:to-equal (file-name-as-directory temp-dir))))))

(describe "clojure-ts-project-relative-path"
(cl-letf (((symbol-function 'clojure-ts-project-dir) (lambda () project-dir)))
(expect (clojure-ts-project-relative-path clj-file-path)
:to-equal project-relative-clj-file-path)))

(describe "clojure-ts-expected-ns"
(it "should return the namespace matching a path"
(cl-letf (((symbol-function 'clojure-ts-project-relative-path)
(lambda (&optional _current-buffer-file-name)
project-relative-clj-file-path)))
(expect (clojure-ts-expected-ns clj-file-path)
:to-equal clj-file-ns)))

(it "should return the namespace even without a path"
(cl-letf (((symbol-function 'clojure-ts-project-relative-path)
(lambda (&optional _current-buffer-file-name)
project-relative-clj-file-path)))
(expect (let ((buffer-file-name clj-file-path))
(clojure-ts-expected-ns))
:to-equal clj-file-ns)))))

(describe "clojure-ts-find-ns"
(it "should find common namespace declarations"
(with-clojure-ts-buffer "(ns foo)"
Expand Down
1 change: 1 addition & 0 deletions test/samples/deps-project/deps.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{:paths ["src/clj"]}
8 changes: 8 additions & 0 deletions test/samples/deps-project/src/clj/hello-clj/world.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
(ns hello-clj.world
(:require
;; This is a comment
[clojure.string :as str]
;; Hello world
[clojure.math :as math])
(:import
(java.util.time Instant ZonedDateTime)))
1 change: 1 addition & 0 deletions test/samples/deps-project/src/clj/hello/world.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
(ns hello.world)
2 changes: 1 addition & 1 deletion test/samples/refactoring.clj
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,4 @@
clojure.lang.IPersistentMap
(set-parameter [])
(set-parameter [m ^PreparedStatement s i]
(.setObject| s i (->pgobject m))))
(.setObject s i (->pgobject m))))