diff --git a/.dir-locals.el b/.dir-locals.el index 0e1f6055c..14b1c7804 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -8,14 +8,10 @@ (fill-column . 80) (sentence-end-double-space . t) (emacs-lisp-docstring-fill-column . 75) - ;; slightly increase the maximum (applies to checkdoc and the byte compiler alike) - (byte-compile-docstring-max-column 100) (checkdoc-symbol-words . ("top-level" "major-mode" "macroexpand-all" "print-level" "print-length")) (checkdoc-package-keywords-flag) (checkdoc-arguments-in-order-flag) (checkdoc-verb-check-experimental-flag) - ;; allow commas to indicate that the first sentence continues, which enables longer first sentences - (checkdoc-permit-comma-termination-flag t) (elisp-lint-indent-specs . ((if-let* . 2) (when-let* . 1) (let* . defun) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 666767f5f..f64e3cc75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,7 @@ jobs: matrix: os: [macos-latest, ubuntu-latest, windows-latest] emacs_version: ['26.3', '27.2', '28.2', '29.1'] + java_version: ['11', '17'] steps: - name: Set up Emacs @@ -67,7 +68,7 @@ jobs: with: distribution: 'temurin' # shadow requires java 11 - java-version: 11 + java-version: ${{matrix.java_version}} - name: Install Clojure Tools # Use SHA until @@ -91,3 +92,11 @@ jobs: # be GH connectivity runner issues. We attempt to address this # problem by rerunning the tests more than once. eldev -p -dtTC test --test-type integration || eldev -p -dtTC test --test-type integration + + - name: Run tests that need enrich-classpath + if: "!startsWith(matrix.os, 'windows')" + run: | + cd dev; ../clojure.sh clojure -M:gen; cd - + wc -l test/File.edn + eldev -p -dtTC test --test-type enrich || eldev -p -dtTC test --test-type enrich + diff --git a/.gitignore b/.gitignore index cd83f8daf..33e94bd77 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ cider-pkg.el cider-refcard.aux cider-refcard.log doc/auto/ +test/*.edn diff --git a/Eldev b/Eldev index 5dc49631f..43a232c66 100644 --- a/Eldev +++ b/Eldev @@ -11,12 +11,19 @@ (eldev-add-loading-roots 'test "test/utils") (eldev-add-extra-dependencies 'runtime '(:package logview :optional t)) +;; slightly increase the maximum (applies to checkdoc and the byte compiler alike) +(setq byte-compile-docstring-max-column 100) + +;; allow commas to indicate that the first sentence continues, which enables longer first sentences +(setq checkdoc-permit-comma-termination-flag t) + (defvar cider-test-type 'main) (setf eldev-standard-excludes `(:or ,eldev-standard-excludes ;; Avoid including files in test "projects". (eldev-pcase-exhaustive cider-test-type (`main "./test/*/") (`integration '("./test/" "!./test/integration")) + (`enrich '("./test/" "!./test/enrich")) (`all '("./test/*/" "!./test/integration"))) "test/integration/projects" ;; This file is _supposed_ to be excluded @@ -24,12 +31,12 @@ "test/cider-tests--no-auto.el")) (eldev-defoption cider-test-selection (type) - "Select tests to run; type can be `main', `integration' or `all'" + "Select tests to run; type can be `main', `integration', `enrich' or `all'" :options (-T --test-type) :for-command test :value TYPE :default-value cider-test-type - (unless (memq (intern type) '(main integration all)) + (unless (memq (intern type) '(main integration enrich all)) (signal 'eldev-wrong-option-usage `("unknown test type `%s'" ,type))) (setf cider-test-type (intern type))) diff --git a/Makefile b/Makefile index 62681cc42..3c30afe99 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,15 @@ lint: clean compile: clean eldev -dtT compile --warnings-as-errors -test-all: clean +test/File.edn: + cd dev; ../clojure.sh clojure -M:gen + +test-all: clean test/File.edn eldev -dtT -p test --test-type all +test-enrich: clean test/File.edn + eldev -dtT -p test --test-type enrich + test-integration: clean eldev -dtT -p test --test-type integration diff --git a/cider-client.el b/cider-client.el index 61c335933..dd62f25e6 100644 --- a/cider-client.el +++ b/cider-client.el @@ -34,6 +34,7 @@ (require 'spinner) (require 'cider-connection) +(require 'cider-completion-context) (require 'cider-common) (require 'cider-util) (require 'nrepl-client) @@ -520,7 +521,7 @@ When multiple matching vars are returned you'll be prompted to select one, unless ALL is truthy." (when (and var (not (string= var ""))) (let ((var-info (cond - ((cider-nrepl-op-supported-p "info") (cider-sync-request:info var)) + ((cider-nrepl-op-supported-p "info") (cider-sync-request:info var nil nil (cider-completion-get-context t))) ((cider-nrepl-op-supported-p "lookup") (cider-sync-request:lookup var)) (t (cider-fallback-eval:info var))))) (if all var-info (cider--var-choice var-info))))) @@ -528,7 +529,7 @@ unless ALL is truthy." (defun cider-member-info (class member) "Return the CLASS MEMBER's info as an alist with list cdrs." (when (and class member) - (cider-sync-request:info nil class member))) + (cider-sync-request:info nil class member (cider-completion-get-context t)))) ;;; Requests @@ -646,13 +647,14 @@ CONTEXT represents a completion context for compliment." nil 'abort-on-input)) -(defun cider-sync-request:info (symbol &optional class member) - "Send \"info\" op with parameters SYMBOL or CLASS and MEMBER." +(defun cider-sync-request:info (symbol &optional class member context) + "Send \"info\" op with parameters SYMBOL or CLASS and MEMBER, honor CONTEXT." (let ((var-info (thread-first `("op" "info" "ns" ,(cider-current-ns) ,@(when symbol `("sym" ,symbol)) ,@(when class `("class" ,class)) - ,@(when member `("member" ,member))) + ,@(when member `("member" ,member)) + ,@(when context `("context" ,context))) (cider-nrepl-send-sync-request (cider-current-repl))))) (if (member "no-info" (nrepl-dict-get var-info "status")) nil @@ -669,13 +671,14 @@ CONTEXT represents a completion context for compliment." nil (nrepl-dict-get var-info "info")))) -(defun cider-sync-request:eldoc (symbol &optional class member) - "Send \"eldoc\" op with parameters SYMBOL or CLASS and MEMBER." +(defun cider-sync-request:eldoc (symbol &optional class member context) + "Send \"eldoc\" op with parameters SYMBOL or CLASS and MEMBER, honor CONTEXT." (when-let* ((eldoc (thread-first `("op" "eldoc" "ns" ,(cider-current-ns) ,@(when symbol `("sym" ,symbol)) ,@(when class `("class" ,class)) - ,@(when member `("member" ,member))) + ,@(when member `("member" ,member)) + ,@(when context `("context" ,context))) (cider-nrepl-send-sync-request (cider-current-repl) 'abort-on-input)))) (if (member "no-eldoc" (nrepl-dict-get eldoc "status")) diff --git a/cider-completion-context.el b/cider-completion-context.el new file mode 100644 index 000000000..7171788d7 --- /dev/null +++ b/cider-completion-context.el @@ -0,0 +1,122 @@ +;;; cider-completion-context.el --- Context parsing -*- lexical-binding: t -*- + +;; Copyright © 2013-2023 Bozhidar Batsov, Artur Malabarba and CIDER contributors +;; +;; Author: Bozhidar Batsov +;; Artur Malabarba + +;; 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 . + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Context-parsing utilities. Extracted from cider-completion.el. + +;;; Code: + +(defcustom cider-completion-use-context t + "When true, uses context at point to improve completion suggestions." + :type 'boolean + :group 'cider + :package-version '(cider . "0.7.0")) + +(defun cider-completion--bounds-of-non-string-symbol-at-point () + "Returns the bounds of the symbol at point, unless it's inside a string." + (let ((sap (symbol-at-point))) + (when (and sap (not (nth 3 (syntax-ppss)))) + (bounds-of-thing-at-point 'symbol)))) + +(defun cider-completion-symbol-start-pos () + "Find the starting position of the symbol at point, unless inside a string." + (car (cider-completion--bounds-of-non-string-symbol-at-point))) + +(defun cider-completion-symbol-end-pos () + "Find the end position of the symbol at point, unless inside a string." + (cdr (cider-completion--bounds-of-non-string-symbol-at-point))) + +(defun cider-completion-get-info-context-at-point () + "Extract a context at point that is suitable for eldoc and info ops. +Note that this context is slightly different than that of +`cider-completion-get-context-at-point': this one does not include +the current symbol at point." + (when (save-excursion + (condition-case _ + (progn + (up-list) + (check-parens) + t) + (scan-error nil) + (user-error nil))) + (save-excursion + (let* ((pref-start (cider-completion-symbol-start-pos)) + (context (cider-defun-at-point)) + (end (cider-completion-symbol-end-pos)) + (_ (beginning-of-defun-raw)) + (expr-start (point)) + (_ (if (derived-mode-p 'cider-repl-mode) + (goto-char (point-max)) + (end-of-defun))) + (expr-end (point))) + (string-remove-suffix "\n" + (concat (when pref-start (substring context 0 (- pref-start expr-start))) + "__prefix__" + (substring context (- (- expr-end end))))))))) + +(defun cider-completion-get-context-at-point () + "Extract the context at point. +If point is not inside the list, returns nil; otherwise return \"top-level\" +form, with symbol at point replaced by __prefix__." + (when (save-excursion + (condition-case _ + (progn + (up-list) + (check-parens) + t) + (scan-error nil) + (user-error nil))) + (save-excursion + (let* ((pref-end (point)) + (pref-start (cider-completion-symbol-start-pos)) + (context (cider-defun-at-point)) + (_ (beginning-of-defun-raw)) + (expr-start (point))) + (concat (when pref-start (substring context 0 (- pref-start expr-start))) + "__prefix__" + (substring context (- pref-end expr-start))))))) + +(defvar cider-completion-last-context nil) + +(defun cider-completion-get-context (&optional info) + "Extract context depending (maybe of INFO type). + +Output depends on `cider-completion-use-context' and the current major mode." + (let ((context (if cider-completion-use-context + ;; We use ignore-errors here since grabbing the context + ;; might fail because of unbalanced parens, or other + ;; technical reasons, yet we don't want to lose all + ;; completions and throw error to user because of that. + (or (ignore-errors + (if info + (cider-completion-get-info-context-at-point) + (cider-completion-get-context-at-point))) + "nil") + "nil"))) + (if (string= cider-completion-last-context context) + ":same" + (setq cider-completion-last-context context) + context))) + +(provide 'cider-completion-context) +;;; cider-completion-context.el ends here diff --git a/cider-completion.el b/cider-completion.el index 944c6e972..efce14004 100644 --- a/cider-completion.el +++ b/cider-completion.el @@ -31,16 +31,12 @@ (require 'cider-client) (require 'cider-common) +(require 'cider-completion-context) (require 'cider-doc) +(require 'cider-docstring) (require 'cider-eldoc) (require 'nrepl-dict) -(defcustom cider-completion-use-context t - "When true, uses context at point to improve completion suggestions." - :type 'boolean - :group 'cider - :package-version '(cider . "0.7.0")) - (defcustom cider-annotate-completion-candidates t "When true, annotate completion candidates with some extra information." :type 'boolean @@ -116,55 +112,6 @@ if the candidate is not namespace-qualified." :group 'cider :package-version '(cider . "0.9.0")) -(defvar cider-completion-last-context nil) - -(defun cider-completion-symbol-start-pos () - "Find the starting position of the symbol at point, unless inside a string." - (let ((sap (symbol-at-point))) - (when (and sap (not (nth 3 (syntax-ppss)))) - (car (bounds-of-thing-at-point 'symbol))))) - -(defun cider-completion-get-context-at-point () - "Extract the context at point. -If point is not inside the list, returns nil; otherwise return \"top-level\" -form, with symbol at point replaced by __prefix__." - (when (save-excursion - (condition-case _ - (progn - (up-list) - (check-parens) - t) - (scan-error nil) - (user-error nil))) - (save-excursion - (let* ((pref-end (point)) - (pref-start (cider-completion-symbol-start-pos)) - (context (cider-defun-at-point)) - (_ (beginning-of-defun-raw)) - (expr-start (point))) - (concat (when pref-start (substring context 0 (- pref-start expr-start))) - "__prefix__" - (substring context (- pref-end expr-start))))))) - -(defun cider-completion-get-context () - "Extract context depending on `cider-completion-use-context' and major mode." - (let ((context (if (and cider-completion-use-context - ;; Important because `beginning-of-defun' and - ;; `ending-of-defun' work incorrectly in the REPL - ;; buffer, so context extraction fails there. - (derived-mode-p 'clojure-mode)) - ;; We use ignore-errors here since grabbing the context - ;; might fail because of unbalanced parens, or other - ;; technical reasons, yet we don't want to lose all - ;; completions and throw error to user because of that. - (or (ignore-errors (cider-completion-get-context-at-point)) - "nil") - "nil"))) - (if (string= cider-completion-last-context context) - ":same" - (setq cider-completion-last-context context) - context))) - (defun cider-completion--parse-candidate-map (candidate-map) "Get \"candidate\" from CANDIDATE-MAP. Put type and ns properties on the candidate" @@ -253,7 +200,7 @@ performed by `cider-annotate-completion-function'." (cider-complete prefix) prefix pred))))) :annotation-function #'cider-annotate-symbol :company-kind #'cider-company-symbol-kind - :company-doc-buffer #'cider-create-doc-buffer + :company-doc-buffer #'cider-create-compact-doc-buffer :company-location #'cider-company-location :company-docsig #'cider-company-docsig)))) @@ -281,11 +228,10 @@ in the buffer." (defun cider-company-docsig (thing) "Return signature for THING." - (let* ((eldoc-info (cider-eldoc-info thing)) - (ns (lax-plist-get eldoc-info "ns")) - (symbol (lax-plist-get eldoc-info "symbol")) - (arglists (lax-plist-get eldoc-info "arglists"))) - (when eldoc-info + (when-let ((eldoc-info (cider-eldoc-info thing))) + (let* ((ns (lax-plist-get eldoc-info "ns")) + (symbol (lax-plist-get eldoc-info "symbol")) + (arglists (lax-plist-get eldoc-info "arglists"))) (format "%s: %s" (cider-eldoc-format-thing ns symbol thing (cider-eldoc-thing-type eldoc-info)) diff --git a/cider-doc.el b/cider-doc.el index 7f7db0108..3bf3ad24e 100644 --- a/cider-doc.el +++ b/cider-doc.el @@ -26,6 +26,7 @@ ;;; Code: (require 'cider-common) +(require 'cider-docstring) (require 'subr-x) (require 'cider-util) (require 'cider-popup) @@ -265,10 +266,17 @@ opposite of what that option dictates." (defconst cider-doc-buffer "*cider-doc*") -(defun cider-create-doc-buffer (symbol) - "Populates *cider-doc* with the documentation for SYMBOL." +(defun cider-create-doc-buffer (symbol &optional compact) + "Populates *cider-doc* with the documentation for SYMBOL, +favoring a COMPACT format if specified." (when-let* ((info (cider-var-info symbol))) - (cider-docview-render (cider-make-popup-buffer cider-doc-buffer nil 'ancillary) symbol info))) + (cider-docview-render (cider-make-popup-buffer cider-doc-buffer nil 'ancillary) symbol info compact))) + +(defun cider-create-compact-doc-buffer (symbol) + "Populates *cider-doc* with the documentation for SYMBOL. + +Favors a compact rendering of docstrings" + (cider-create-doc-buffer symbol :compact)) (defun cider-doc-lookup (symbol) "Look up documentation for SYMBOL." @@ -399,8 +407,9 @@ Same for `jar:file:...!/' segments." file)) result))) -(defun cider-docview-render-info (buffer info) - "Emit into BUFFER formatted INFO for the Clojure or Java symbol." +(defun cider-docview-render-info (buffer info &optional compact for-tooltip) + "Emit into BUFFER formatted INFO for the Clojure or Java symbol, +in a COMPACT format is specified, FOR-TOOLTIP if specified." (let* ((ns (nrepl-dict-get info "ns")) (name (nrepl-dict-get info "name")) (added (nrepl-dict-get info "added")) @@ -410,10 +419,20 @@ Same for `jar:file:...!/' segments." (builtin (nrepl-dict-get info "built-in")) ;; babashka specific (forms (when-let* ((str (nrepl-dict-get info "forms-str"))) (split-string str "\n"))) - (args (when-let* ((str (nrepl-dict-get info "arglists-str"))) - (split-string str "\n"))) - (doc (or (nrepl-dict-get info "doc") - "Not documented.")) + (args (or (nrepl-dict-get info "annotated-arglists") + (when-let* ((str (nrepl-dict-get info "arglists-str"))) + (split-string str "\n")))) + (rendered-fragments (cider--render-docstring (list "doc-fragments" (unless compact + (nrepl-dict-get info "doc-fragments")) + "doc-block-tags-fragments" (nrepl-dict-get info "doc-block-tags-fragments") + "doc-first-sentence-fragments" (nrepl-dict-get info "doc-first-sentence-fragments")))) + (fetched-doc (nrepl-dict-get info "doc")) + (doc (or rendered-fragments + (if compact + (cider-docstring--dumb-trim fetched-doc) + fetched-doc) + (unless compact + "Not documented."))) (url (nrepl-dict-get info "url")) (class (nrepl-dict-get info "class")) (member (nrepl-dict-get info "member")) @@ -426,24 +445,35 @@ Same for `jar:file:...!/' segments." (see-also (nrepl-dict-get info "see-also"))) (cider--help-setup-xref (list #'cider-doc-lookup (format "%s/%s" ns name)) nil buffer) (with-current-buffer buffer - (cl-flet ((emit (text &optional face) + (cl-flet ((emit (text &optional face sep) (insert (if face (propertize text 'font-lock-face face) text) - "\n"))) + (or sep "\n")))) (emit (if class java-name clj-name) 'font-lock-function-name-face) (when super - (emit (concat " Extends: " (cider-font-lock-as 'java-mode super)))) + (emit (concat "Extends: " (cider-font-lock-as 'java-mode super)))) (when ifaces (emit (concat "Implements: " (cider-font-lock-as 'java-mode (car ifaces)))) - (dolist (iface (cdr ifaces)) - (emit (concat " "(cider-font-lock-as 'java-mode iface))))) + ;; choose a separator that will produce correct alignment on monospace and regular fonts: + (let ((sep (if for-tooltip + " " + " "))) + (dolist (iface (cdr ifaces)) + (emit (concat sep (cider-font-lock-as 'java-mode iface)))))) (when (or super ifaces) (insert "\n")) - (when-let* ((forms (or forms args))) + (when-let* ((forms (or forms args)) + (forms (delq nil (mapcar (lambda (f) + (unless (equal f "nil") + f)) + forms)))) (dolist (form forms) - (insert " ") - (emit (cider-font-lock-as-clojure form)))) + (emit (cider-font-lock-as-clojure form) + nil)) + (when compact + ;; Compensate for the newlines not `emit`ted in the previous call: + (insert "\n"))) (when special (emit "Special Form" 'font-lock-keyword-face)) (when macro @@ -454,9 +484,12 @@ Same for `jar:file:...!/' segments." (emit (concat "Added in " added) 'font-lock-comment-face)) (when depr (emit (concat "Deprecated in " depr) 'font-lock-keyword-face)) - (if class + (if (and doc class (not rendered-fragments)) (cider-docview-render-java-doc (current-buffer) doc) - (emit (concat " " doc))) + (when doc + (emit (if rendered-fragments + doc + (concat " " doc))))) (when url (insert "\n Please see ") (insert-text-button url @@ -465,7 +498,7 @@ Same for `jar:file:...!/' segments." 'action (lambda (x) (browse-url (button-get x 'url)))) (insert "\n")) - (when javadoc + (when (and (not compact) javadoc) (insert "\n\nFor additional documentation, see the ") (insert-text-button "Javadoc" 'url javadoc @@ -483,18 +516,20 @@ Same for `jar:file:...!/' segments." 'action (lambda (_) (cider-browse-spec (format "%s/%s" ns name)))) (insert "\n\n")) - (if (and cider-docview-file (not (string= cider-docview-file ""))) - (progn - (insert (propertize (if class java-name clj-name) - 'font-lock-face 'font-lock-function-name-face) - " is defined in ") - (insert-text-button (cider--abbreviate-file-protocol cider-docview-file) - 'follow-link t - 'action (lambda (_x) - (cider-docview-source))) - (insert ".")) - (insert "Definition location unavailable.")) - (when see-also + (unless compact + (if (and cider-docview-file (not (string= cider-docview-file ""))) + (progn + (insert (propertize (if class java-name clj-name) + 'font-lock-face 'font-lock-function-name-face) + " is defined in ") + (insert-text-button (cider--abbreviate-file-protocol cider-docview-file) + 'follow-link t + 'action (lambda (_x) + (cider-docview-source))) + (insert ".")) + (insert "Definition location unavailable."))) + (when (and (not compact) + see-also) (insert "\n\n Also see: ") (mapc (lambda (ns-sym) (let* ((ns-sym-split (split-string ns-sym "/")) @@ -508,7 +543,8 @@ Same for `jar:file:...!/' segments." 'help-function (apply-partially #'cider-doc-lookup symbol))) (insert " ")) see-also)) - (cider--doc-make-xrefs) + (unless compact + (cider--doc-make-xrefs)) (let ((beg (point-min)) (end (point-max))) (nrepl-dict-map (lambda (k v) @@ -517,8 +553,9 @@ Same for `jar:file:...!/' segments." (current-buffer)))) (declare-function cider-set-buffer-ns "cider-mode") -(defun cider-docview-render (buffer symbol info) - "Emit into BUFFER formatted documentation for SYMBOL's INFO." +(defun cider-docview-render (buffer symbol info &optional compact for-tooltip) + "Emit into BUFFER formatted documentation for SYMBOL's INFO, +favoring a COMPACT format if specified, FOR-TOOLTIP if specified." (with-current-buffer buffer (let ((javadoc (nrepl-dict-get info "javadoc")) (file (nrepl-dict-get info "file")) @@ -534,7 +571,7 @@ Same for `jar:file:...!/' segments." (setq-local cider-docview-line line) (remove-overlays) - (cider-docview-render-info buffer info) + (cider-docview-render-info buffer info compact for-tooltip) (goto-char (point-min)) (current-buffer)))) diff --git a/cider-docstring.el b/cider-docstring.el new file mode 100644 index 000000000..56c68022c --- /dev/null +++ b/cider-docstring.el @@ -0,0 +1,173 @@ +;;; cider-docstring.el --- Docstring rendering -*- lexical-binding: t -*- + +;; Copyright © 2013-2023 Bozhidar Batsov and CIDER contributors +;; +;; Author: Bozhidar Batsov + +;; 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 . + +;; This file is not part of GNU Emacs. + +;;; Commentary: + +;; Utilities for rendering a docstring into a shorter, especially-formatted string +;; that will look nice in UIs. + +;;; Code: + +(require 'cl-lib) +(require 'shr) + +(defun cider--to-java-string (s) + "Convert string S to a Java-formatted string with syntax highlighting." + (with-temp-buffer + (insert s) + (java-mode) + (font-lock-ensure) + (buffer-string))) + +(defsubst cider--render-pre* (dom) + "Render DOM nodes, formatting them them as Java if they are strings." + (dolist (sub (dom-children dom)) + (if (stringp sub) + (shr-insert (cider--to-java-string sub)) + (shr-descend sub)))) + +(defun cider--render-pre (dom) + "Render DOM nodes without folding, a monospaced font, and Java syntax coloring." + (let ((shr-folding-mode 'none) + (shr-current-font 'default)) + (cider--render-pre* dom))) + +(defun cider--string-rstrip-newlines (str) + "Remove newlines at the end of STR." + (if (string-match "\\([\n\r]+\\)$" str) + (replace-match "" nil nil str) + str)) + +(defun cider--html-to-propertized-string (html-string) + "Convert an HTML-STRING into a propertized string using SHR." + (with-temp-buffer + (insert html-string) + (cider--string-rstrip-newlines ;; shr-insert-document adds a final newline. Plain text fragments are responsible for separating fragments if needed.. + (let ((dom (libxml-parse-html-region (point-min) (point-max)))) + (erase-buffer) + (shr-insert-document dom) + (buffer-string))))) + +(defun cider--fragments-to-s (fragments) + "Convert FRAGMENTS into a concatenated string representation. +If a given fragment is of html type, it's converted to a propertized string; +otherwise, it's included as-is." + (when (and fragments + (> (length fragments) + 0)) + (string-trim (cl-reduce (lambda (new-s fragment) + (let* ((html? (equal "html" (nrepl-dict-get fragment "type"))) + (v (nrepl-dict-get fragment "content"))) + (concat new-s (if html? + (let ((shr-use-fonts nil) + (shr-external-rendering-functions '((pre . cider--render-pre)))) + (cider--html-to-propertized-string v)) + v)))) + fragments + :initial-value "")))) + +(defcustom cider-docstring-max-lines 20 + "The maximum number of docstring lines that will be rendered in a UI widget (or the echo area). + +Note that `cider-docstring' will trim thing smartly, for Java doc comments: +* First, the whole doc comment will be attempted to be rendered. +* If that exceeds `cider-docstring-max-lines', + we will use only the first sentence and the block tags + (that is, the params/throws/returns info). +* If that exceeds `cider-docstring-max-lines', we will use only the block tags. +* If that exceeds `cider-docstring-max-lines', we will use only the first sentence." + :type 'integer + :group 'cider + :package-version '(cider . "1.8.0")) + +(defun cider--attempt-invalid? (attempt) + "Check if ATTEMPT is either nil or exceeds `cider-docstring-max-lines' in line count." + (or (not attempt) + (and attempt + (> (length (split-string attempt "\n")) + cider-docstring-max-lines)))) + +(defun cider--render-docstring-first-sentence (eldoc-info) + "Render the first sentence of the docstring extracted from ELDOC-INFO." + (when-let ((first-sentence-fragments (lax-plist-get eldoc-info "doc-first-sentence-fragments"))) + (cider--fragments-to-s first-sentence-fragments))) + +(defun cider--render-docstring (eldoc-info) + "Renders the docstring from ELDOC-INFO based on its length and content. +Prioritize rendering as much as possible while staying within `cider-docstring-max-lines'." + (let* ((first-sentence-fragments (lax-plist-get eldoc-info "doc-first-sentence-fragments")) + (body-fragments (lax-plist-get eldoc-info "doc-fragments")) + (block-tags-fragments (lax-plist-get eldoc-info "doc-block-tags-fragments")) + (block-tags-fragments-rendered (cider--fragments-to-s block-tags-fragments)) + (first-sentence-fragments-rendered) ;; mutable, for performance + (first-attempt (when body-fragments + (concat (cider--fragments-to-s body-fragments) + (when block-tags-fragments + "\n\n") + block-tags-fragments-rendered))) + (first-attempt-invalid? (cider--attempt-invalid? first-attempt)) + (second-attempt (when (and first-sentence-fragments + first-attempt-invalid?) + (setq first-sentence-fragments-rendered (cider--fragments-to-s first-sentence-fragments)) + (concat first-sentence-fragments-rendered + (when block-tags-fragments-rendered + "\n\n") + block-tags-fragments-rendered))) + (second-attempt-invalid? (cider--attempt-invalid? second-attempt)) + (third-attempt (when (and block-tags-fragments-rendered + first-attempt-invalid? + second-attempt-invalid?) + block-tags-fragments-rendered)) + (third-attempt-invalid? (cider--attempt-invalid? third-attempt)) + (last-attempt (when (and first-sentence-fragments-rendered + first-attempt-invalid? + second-attempt-invalid? + third-attempt-invalid?) + first-sentence-fragments-rendered))) + (or last-attempt ;; the last attempt has to go first - it takes priority over an attempt deemed invalid. + third-attempt + second-attempt + first-attempt))) + +(defun cider-docstring--dumb-trim (s &optional n) + "Returns up to the first N lines of string S, +adding \"...\" if trimming was necessary. + +N defaults to `cider-docstring-max-lines'. + +Also performs some bare-bones formatting, cleaning up some common whitespace issues." + (when s + (let* ((s (replace-regexp-in-string "\\. " ".\n\n" s)) ;; improve the formatting of e.g. clojure.core/reduce + (n (or n cider-docstring-max-lines)) + (lines (split-string s "\n")) + (lines-length (length lines)) + (selected-lines (cl-subseq lines 0 (min n lines-length))) + (result (mapconcat (lambda (f) + ;; Remove spaces at the beginning of each line, as it is common in many clojure.core defns: + (replace-regexp-in-string "\\`[ ]+" "" f)) + selected-lines + "\n"))) + (if (> lines-length n) + (concat result "...") + result)))) + +(provide 'cider-docstring) +;;; cider-docstring.el ends here diff --git a/cider-eldoc.el b/cider-eldoc.el index 0a43203bf..a328f648c 100644 --- a/cider-eldoc.el +++ b/cider-eldoc.el @@ -33,6 +33,8 @@ (require 'cider-client) (require 'cider-common) ; for cider-symbol-at-point +(require 'cider-completion-context) +(require 'cider-docstring) (require 'subr-x) (require 'cider-util) (require 'nrepl-dict) @@ -214,8 +216,14 @@ THING is the variable name. ELDOC-INFO is a p-list containing the eldoc information." (let* ((ns (lax-plist-get eldoc-info "ns")) (symbol (lax-plist-get eldoc-info "symbol")) - (docstring (lax-plist-get eldoc-info "docstring")) - (formatted-var (cider-eldoc-format-thing ns symbol thing 'var))) + (docstring (or (cider--render-docstring-first-sentence eldoc-info) + (cider--render-docstring eldoc-info) + (cider-docstring--dumb-trim (lax-plist-get eldoc-info "docstring")))) + ;; if it's a single class (and not multiple class candidates), that's it + (maybe-class (car (lax-plist-get eldoc-info "class"))) + (formatted-var (or (when maybe-class + (cider-propertize maybe-class 'var)) + (cider-eldoc-format-thing ns symbol thing 'var)))) (when docstring (cider-eldoc-format-sym-doc formatted-var ns docstring)))) @@ -382,7 +390,7 @@ Otherwise return the eldoc of the first symbol of the sexp." (_ thing))) (defun cider-eldoc-info (thing) - "Return the info for THING. + "Return the info for THING (as string). This includes the arglist and ns and symbol name (if available)." (let ((thing (cider-eldoc--convert-ns-keywords thing))) (when (and (cider-nrepl-op-supported-p "eldoc") @@ -410,7 +418,7 @@ This includes the arglist and ns and symbol name (if available)." ;; generic case (t (if (equal thing (car cider-eldoc-last-symbol)) (cadr cider-eldoc-last-symbol) - (when-let* ((eldoc-info (cider-sync-request:eldoc thing))) + (when-let* ((eldoc-info (cider-sync-request:eldoc thing nil nil (cider-completion-get-context t)))) (let* ((arglists (nrepl-dict-get eldoc-info "eldoc")) (docstring (nrepl-dict-get eldoc-info "docstring")) (type (nrepl-dict-get eldoc-info "type")) @@ -425,9 +433,15 @@ This includes the arglist and ns and symbol name (if available)." name (format ".%s" member))) (eldoc-plist (list "ns" ns-or-class + "class" class "symbol" name-or-member "arglists" arglists "docstring" docstring + "doc-fragments" (nrepl-dict-get eldoc-info "doc-fragments") + "doc-first-sentence-fragments" (nrepl-dict-get eldoc-info + "doc-first-sentence-fragments") + "doc-block-tags-fragments" (nrepl-dict-get eldoc-info + "doc-block-tags-fragments") "type" type))) ;; add context dependent args if requested by defcustom ;; do not cache this eldoc info to avoid showing info diff --git a/cider-mode.el b/cider-mode.el index bbb7934e8..96ff6ebe0 100644 --- a/cider-mode.el +++ b/cider-mode.el @@ -39,6 +39,7 @@ (require 'cider-doc) ; required only for the menu (require 'cider-profile) ; required only for the menu (require 'cider-completion) +(require 'cider-completion-context) (require 'cider-inspector) (require 'cider-find) (require 'cider-xref-backend) @@ -982,7 +983,7 @@ before point." "Return a string of what would be displayed by `cider-docview-render'. SYM and INFO is passed to `cider-docview-render'" (with-temp-buffer - (cider-docview-render (current-buffer) sym info) + (cider-docview-render (current-buffer) sym info :compact :for-tooltip) ;; :compact because we don't want huge tooltips - especially for Java (goto-char (point-max)) (forward-line -1) (replace-regexp-in-string @@ -1018,7 +1019,7 @@ See \(info \"(elisp) Special Properties\")" (let* ((locals (nrepl-dict-get cider--debug-mode-response "locals")) (local-val (cadr (assoc sym locals)))) (format " with value:\n%s" local-val)))) - (let* ((info (cider-sync-request:info sym)) + (let* ((info (cider-sync-request:info sym nil nil (cider-completion-get-context t))) (candidates (nrepl-dict-get info "candidates"))) (if candidates (concat "There were ambiguities resolving this symbol:\n\n" diff --git a/cider-util.el b/cider-util.el index ddabbb728..4ec1c8fb4 100644 --- a/cider-util.el +++ b/cider-util.el @@ -109,7 +109,9 @@ If BOUNDS is non-nil, return a list of its starting and ending position instead." (save-excursion (save-match-data - (end-of-defun) + (if (derived-mode-p 'cider-repl-mode) + (goto-char (point-max)) ;; in repls, end-of-defun won't work, so we perform the closest reasonable thing + (end-of-defun)) (let ((end (point))) (clojure-backward-logical-sexp 1) (cider--text-or-limits bounds (point) end))))) diff --git a/dev/deps.edn b/dev/deps.edn new file mode 100644 index 000000000..bda81de4a --- /dev/null +++ b/dev/deps.edn @@ -0,0 +1,5 @@ +;; This file cannot live at CIDER's project root, because its presence can affect clojure-mode logic, making some tests fail. +{:deps {cider/orchard {:mvn/version "RELEASE"}} + :paths ["."] + :aliases {:gen {:jvm-opts ["-Dclojure.main.report=stderr"] + :main-opts ["-m" "generate-html-fragments"]}}} diff --git a/dev/generate_html_fragments.clj b/dev/generate_html_fragments.clj new file mode 100644 index 000000000..7c7c8e816 --- /dev/null +++ b/dev/generate_html_fragments.clj @@ -0,0 +1,33 @@ +(ns generate-html-fragments + "This script writes test/File.edn files, backing docstring-related Elisp tests." + (:require + [clojure.java.io :as io] + [clojure.pprint :refer [pprint]] + [orchard.java]) + (:import + (java.io File))) + +;; Ensure that it is requireable - if Orchard internally falls back to other ns, this script won't work properly: +(require 'orchard.java.parser-next) + +(defn -main [& _] + (doseq [class-symbol [`Thread `String `Object `File 'java.util.Map] + :let [{:keys [members] :as x} (orchard.java/source-info class-symbol) + members (->> members vals (map vals) (reduce into)) + all (conj members x) + filename (str "../test" File/separator (-> class-symbol eval .getSimpleName) ".edn")]] + (-> filename io/file .delete) + (with-open [w (io/writer filename :append true)] + (.write w "[") + (doseq [{:keys [doc-fragments doc-first-sentence-fragments doc-block-tags-fragments] :as i} all + :when (or (seq doc-fragments) + (seq doc-first-sentence-fragments) + (seq doc-block-tags-fragments))] + (.write w (with-out-str + (pprint (select-keys i [:doc-fragments + :doc-first-sentence-fragments + :doc-block-tags-fragments])))) + (.write w "\n")) + (.write w "]")) + (println "Processed" (-> filename io/file str))) + (shutdown-agents)) diff --git a/doc/modules/ROOT/pages/usage/code_completion.adoc b/doc/modules/ROOT/pages/usage/code_completion.adoc index fb684f5d2..0fe90aa9b 100644 --- a/doc/modules/ROOT/pages/usage/code_completion.adoc +++ b/doc/modules/ROOT/pages/usage/code_completion.adoc @@ -39,8 +39,27 @@ is already properly indented. While the standard Emacs tooling works just fine, we suggest that CIDER users consider using -http://company-mode.github.io/[`company-mode`] instead. Company -can be used for auto-completion in both source code and REPL buffers. +http://company-mode.github.io/[`company-mode`] or https://github.com/minad/corfu[`corfu-mode`] instead. These +can be used for auto-completion in both source code and REPL buffers, with the following advantages: + +* A nicer UI. +* Integration with Clojure docstrings and Java doc comments. + +=== Configuration + +Both company-mode and corfu-mode are affected by the following CIDER configuration options: + +* `cider-docstring-max-lines` (default `20`) controls how many lines, at most, of this docstring +will be included (in a popup or the echo area, depending on your setup) while offering completions. +It's worth noting, for Java documentation, CIDER doesn't simply trim lines, +but it looks at the structure and tries to find the largest combination of these that fits into `cider-docstring-max-lines`: + ** The entire comment body, followed by its "block tags" (Returns/Throws/Params information) + ** The first sentence of the comment, followed by the block tags + ** The block tags + ** The first sentence of the comment. + +=== company-mode installation + To install `company-mode`: kbd:[M-x] `package-install` kbd:[RET] `company` kbd:[RET] @@ -79,6 +98,21 @@ you can add this to your config: (global-set-key (kbd "TAB") #'company-indent-or-complete-common) ---- +Company's documentation mechanism and CIDER's documentation facilities are integrated. + +While a completion is being offered to you, you can hit (kbd:[F1]) +(the default `company-show-doc-buffer` key binding) for displaying documentation +and arglists under a temporary `*cider-doc*` buffer. + +In order for Company to always show docstrings and other metadata under a temporary `*cider-doc*` buffer, +without needing to hit an extra key, please customize: + +[source,lisp] +---- +;; (You may want to do this as a setq-local within a clojure-mode-hook instead) +(custom-set-variables '(company-auto-update-doc t)) +---- + === Fuzzy candidate matching By default `company-mode` will provide completion candidates with the diff --git a/doc/modules/ROOT/pages/usage/working_with_documentation.adoc b/doc/modules/ROOT/pages/usage/working_with_documentation.adoc index 5d2de0a60..3cace4ae4 100644 --- a/doc/modules/ROOT/pages/usage/working_with_documentation.adoc +++ b/doc/modules/ROOT/pages/usage/working_with_documentation.adoc @@ -18,6 +18,10 @@ via the command `cider-javadoc`. This requires `enrich-classpath` to be enabled. +NOTE: if using `enrich-classpath`, Java doc comments are available and rendered in the same way that Clojure docstrings are. +They're often much more handy than opening Javadoc in a browser. Starting from CIDER 1.8.0, +the HTML-like language that they use is nicely rendered into syntax-colored strings, well-aligned tables, etc + == ClojureDocs CIDER provides integration with the popular https://clojuredocs.org/[ClojureDocs service]. diff --git a/test/cider-completion-context-tests.el b/test/cider-completion-context-tests.el new file mode 100644 index 000000000..fcb056705 --- /dev/null +++ b/test/cider-completion-context-tests.el @@ -0,0 +1,48 @@ +;;; cider-completion-context-tests.el -*- lexical-binding: t; -*- + +;; Copyright © 2012-2023 Bozhidar Batsov + +;; Author: Bozhidar Batsov + +;; This file is NOT part of GNU Emacs. + +;; 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 `http://www.gnu.org/licenses/'. + +;;; Commentary: + +;; This file is part of CIDER + +;;; Code: + +(require 'buttercup) +(require 'cider-completion-context) + +;; Please, for each `describe', ensure there's an `it' block, so that its execution is visible in CI. + +(describe "cider-completion-get-context" + + (describe "when POINT is not at the end of the symbol" + (describe "within a vanilla clojure buffer" + (it "Returns different things depending on the :info param" + (with-clojure-buffer "(ns foo) + +(|.foo \"\")" + (expect (cider-completion-get-context) :to-equal "(__prefix__.foo \"\")") + (expect (cider-completion-get-context :info) :to-equal "(__prefix__ \"\")")))) + + (describe "within a repl" + (it "Returns different things depending on the :info param" + (with-clojure-buffer "user> (.foo|bar \"\")" + (expect (cider-completion-get-context) :to-equal "(__prefix__bar \"\")") + (expect (cider-completion-get-info-context-at-point) :to-equal "(__prefix__ \"\")")))))) diff --git a/test/cider-util-tests.el b/test/cider-util-tests.el index d877a3016..d8997be85 100644 --- a/test/cider-util-tests.el +++ b/test/cider-util-tests.el @@ -33,6 +33,10 @@ ;; Please, for each `describe', ensure there's an `it' block, so that its execution is visible in CI. +(defun with-clojure-buffer--go-to-point () + (when (search-forward "|" nil 'noerror) + (delete-char -1))) + (defmacro with-clojure-buffer (contents &rest body) "Execute BODY in a clojure-mode buffer with CONTENTS @@ -44,8 +48,7 @@ buffer." (delay-mode-hooks (clojure-mode)) (insert ,contents) (goto-char (point-min)) - (when (search-forward "|" nil 'noerror) - (delete-char -1)) + (with-clojure-buffer--go-to-point) ,@body)) ;;; cider-util tests @@ -233,7 +236,17 @@ buffer." (describe "when the param 'bounds is given" (it "returns the bounds of starting and ending positions of the defun" (with-clojure-buffer "a\n\n(defn ...)|\n\nb" - (expect (cider-defun-at-point 'bounds) :to-equal '(4 15)))))) + (expect (cider-defun-at-point 'bounds) :to-equal '(4 15))))) + + (describe "within a repl" + (it "also works" + (with-temp-buffer + (insert "user> (.| \"\")") + (goto-char (point-min)) + (with-clojure-buffer--go-to-point) + (delay-mode-hooks ;; we just want to mark the mode as cider-nrepl, without running other code. + (cider-repl-mode)) + (expect (cider-defun-at-point) :to-equal "(. \"\")"))))) (describe "cider-repl-prompt-function" (it "returns repl prompts" diff --git a/test/enrich/cider-docstring-tests.el b/test/enrich/cider-docstring-tests.el new file mode 100644 index 000000000..04cc8f7f7 --- /dev/null +++ b/test/enrich/cider-docstring-tests.el @@ -0,0 +1,78 @@ +;; -*- lexical-binding: t; -*- + ;;; cider-docstring-tests.el + +;; Copyright © 2012-2023 Bozhidar Batsov + +;; Author: Bozhidar Batsov + +;; This file is NOT part of GNU Emacs. + +;; 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 `http://www.gnu.org/licenses/'. + +;;; Commentary: + +;; This file is part of CIDER + +;;; Code: + +(require 'buttercup) +(require 'cider-docstring) + +;; Please, for each `describe', ensure there's an `it' block, so that its execution is visible in CI. + +(defun cider-render-docstring-test--convert-fragments (fs) + (mapcar (lambda (x) + (nrepl-dict "type" (gethash :type x) + "content" (gethash :content x))) + fs)) + +(describe "cider--render-docstring" + (it "A large corpus of fragments (as produced by Orchard) can be rendered using `shr' without raising errors" + (dolist (class '("Thread" "Object" "File" "String" "Map")) + (let* ((filename (concat default-directory + "test/" + class + ".edn")) + (_ (cl-assert (file-exists-p filename) t)) + (class-contents (with-temp-buffer + (insert-file-contents filename) + (parseedn-read-str (buffer-string))))) + (cl-assert (> (length class-contents) 0) + t) + (dotimes (i (length class-contents)) + (let* ((member (aref class-contents i))) + (cl-assert (> (hash-table-count member) 0) + t) + (gethash :doc-fragments member) + (let* ((doc-first-sentence-fragments (cider-render-docstring-test--convert-fragments + (gethash :doc-first-sentence-fragments member))) + (eldoc-info (list "doc-fragments" (cider-render-docstring-test--convert-fragments + (gethash :doc-fragments member)) + "doc-first-sentence-fragments" doc-first-sentence-fragments + "doc-block-tags-fragments" (cider-render-docstring-test--convert-fragments + (gethash :doc-block-tags-fragments member)))) + (result (cider--render-docstring eldoc-info))) + (cl-assert (stringp result) t (prin1-to-string eldoc-info)) + (expect (stringp result) + :to-be-truthy) + (expect (> (length result) 0) + :to-be-truthy) + (when (> (length doc-first-sentence-fragments) + 0) + (let ((result (cider--render-docstring (list "doc-first-sentence-fragments" doc-first-sentence-fragments)))) + (cl-assert (stringp result) t (prin1-to-string doc-first-sentence-fragments)) + (expect (stringp result) + :to-be-truthy) + (expect (> (length result) 0) + :to-be-truthy))))))))))