diff --git a/CHANGELOG.md b/CHANGELOG.md index 4035a2a..2bfc74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/clojure-ts-mode.el b/clojure-ts-mode.el index 0cbf862..bcb0107 100644 --- a/clojure-ts-mode.el +++ b/clojure-ts-mode.el @@ -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") @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/test/clojure-ts-mode-util-test.el b/test/clojure-ts-mode-util-test.el index 05b0fcc..32f9d29 100644 --- a/test/clojure-ts-mode-util-test.el +++ b/test/clojure-ts-mode-util-test.el @@ -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)" diff --git a/test/samples/deps-project/deps.edn b/test/samples/deps-project/deps.edn new file mode 100644 index 0000000..a29d91e --- /dev/null +++ b/test/samples/deps-project/deps.edn @@ -0,0 +1 @@ +{:paths ["src/clj"]} diff --git a/test/samples/deps-project/src/clj/hello-clj/world.clj b/test/samples/deps-project/src/clj/hello-clj/world.clj new file mode 100644 index 0000000..e573fbb --- /dev/null +++ b/test/samples/deps-project/src/clj/hello-clj/world.clj @@ -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))) diff --git a/test/samples/deps-project/src/clj/hello/world.clj b/test/samples/deps-project/src/clj/hello/world.clj new file mode 100644 index 0000000..ee9b929 --- /dev/null +++ b/test/samples/deps-project/src/clj/hello/world.clj @@ -0,0 +1 @@ +(ns hello.world) diff --git a/test/samples/refactoring.clj b/test/samples/refactoring.clj index 5a87bf7..c2346ee 100644 --- a/test/samples/refactoring.clj +++ b/test/samples/refactoring.clj @@ -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))))