diff options
Diffstat (limited to 'lisp/package/package-describe.el')
| -rw-r--r-- | lisp/package/package-describe.el | 419 |
1 files changed, 419 insertions, 0 deletions
diff --git a/lisp/package/package-describe.el b/lisp/package/package-describe.el new file mode 100644 index 00000000000..15a7f78ffaf --- /dev/null +++ b/lisp/package/package-describe.el | |||
| @@ -0,0 +1,419 @@ | |||
| 1 | ;;; package-describe.el --- Help buffer for packages -*- lexical-binding: t; -*- | ||
| 2 | |||
| 3 | ;; Copyright (C) 2025 Philip Kaludercic | ||
| 4 | |||
| 5 | ;; Author: Philip Kaludercic <philipk@posteo.net> | ||
| 6 | |||
| 7 | ;; This program is free software; you can redistribute it and/or modify | ||
| 8 | ;; it under the terms of the GNU General Public License as published by | ||
| 9 | ;; the Free Software Foundation, either version 3 of the License, or | ||
| 10 | ;; (at your option) any later version. | ||
| 11 | |||
| 12 | ;; This program is distributed in the hope that it will be useful, | ||
| 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 15 | ;; GNU General Public License for more details. | ||
| 16 | |||
| 17 | ;; You should have received a copy of the GNU General Public License | ||
| 18 | ;; along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
| 19 | |||
| 20 | ;;; Commentary: | ||
| 21 | |||
| 22 | ;;; Code: | ||
| 23 | |||
| 24 | (require 'package-core) | ||
| 25 | (require 'package-elpa) | ||
| 26 | (require 'package-misc) | ||
| 27 | (require 'package-install) | ||
| 28 | |||
| 29 | (require 'browse-url) | ||
| 30 | (require 'lisp-mnt) | ||
| 31 | |||
| 32 | (defface package-help-section-name | ||
| 33 | '((t :inherit (bold font-lock-function-name-face))) | ||
| 34 | "Face used on section names in package description buffers." | ||
| 35 | :version "25.1" | ||
| 36 | :group 'package) | ||
| 37 | |||
| 38 | (defun package--print-help-section (name &rest strings) | ||
| 39 | "Print \"NAME: \", right aligned to the 13th column. | ||
| 40 | If more STRINGS are provided, insert them followed by a newline. | ||
| 41 | Otherwise no newline is inserted." | ||
| 42 | (declare (indent 1)) | ||
| 43 | (insert (make-string (max 0 (- 11 (string-width name))) ?\s) | ||
| 44 | (propertize (concat name ": ") 'font-lock-face 'package-help-section-name)) | ||
| 45 | (when strings | ||
| 46 | (apply #'insert strings) | ||
| 47 | (insert "\n"))) | ||
| 48 | |||
| 49 | (defun package--get-description (desc) | ||
| 50 | "Return a string containing the long description of the package DESC. | ||
| 51 | The description is read from the installed package files." | ||
| 52 | ;; Installed packages have nil for kind, so we look for README | ||
| 53 | ;; first, then fall back to the Commentary header. | ||
| 54 | |||
| 55 | ;; We don’t include README.md here, because that is often the home | ||
| 56 | ;; page on a site like github, and not suitable as the package long | ||
| 57 | ;; description. | ||
| 58 | (let ((files '("README-elpa" "README-elpa.md" "README" "README.rst" "README.org")) | ||
| 59 | file | ||
| 60 | (srcdir (package-desc-dir desc)) | ||
| 61 | result) | ||
| 62 | (while (and files | ||
| 63 | (not result)) | ||
| 64 | (setq file (pop files)) | ||
| 65 | (when (file-readable-p (expand-file-name file srcdir)) | ||
| 66 | ;; Found a README. | ||
| 67 | (with-temp-buffer | ||
| 68 | (insert-file-contents (expand-file-name file srcdir)) | ||
| 69 | (setq result (buffer-string))))) | ||
| 70 | |||
| 71 | (or | ||
| 72 | result | ||
| 73 | |||
| 74 | ;; Look for Commentary header. | ||
| 75 | (lm-commentary (expand-file-name | ||
| 76 | (format "%s.el" (package-desc-name desc)) srcdir)) | ||
| 77 | ""))) | ||
| 78 | |||
| 79 | (defun package--describe-add-library-links () | ||
| 80 | "Add links to library names in package description." | ||
| 81 | (while (re-search-forward "\\<\\([-[:alnum:]]+\\.el\\)\\>" nil t) | ||
| 82 | (if (locate-library (match-string 1)) | ||
| 83 | (make-text-button (match-beginning 1) (match-end 1) | ||
| 84 | 'xref (match-string-no-properties 1) | ||
| 85 | 'help-echo "Read this file's commentary" | ||
| 86 | :type 'package--finder-xref)))) | ||
| 87 | |||
| 88 | (defun package-install-button-action (button) | ||
| 89 | "Run `package-install' on the package BUTTON points to. | ||
| 90 | Used for the `action' property of buttons in the buffer created by | ||
| 91 | `describe-package'." | ||
| 92 | (let ((pkg-desc (button-get button 'package-desc))) | ||
| 93 | (when (y-or-n-p (format-message "Install package `%s'? " | ||
| 94 | (package-desc-full-name pkg-desc))) | ||
| 95 | (package-install pkg-desc nil) | ||
| 96 | (describe-package (package-desc-name pkg-desc))))) | ||
| 97 | |||
| 98 | (defun package-delete-button-action (button) | ||
| 99 | "Run `package-delete' on the package BUTTON points to. | ||
| 100 | Used for the `action' property of buttons in the buffer created by | ||
| 101 | `describe-package'." | ||
| 102 | (let ((pkg-desc (button-get button 'package-desc))) | ||
| 103 | (when (y-or-n-p (format-message "Delete package `%s'? " | ||
| 104 | (package-desc-full-name pkg-desc))) | ||
| 105 | (package-delete pkg-desc) | ||
| 106 | (describe-package (package-desc-name pkg-desc))))) | ||
| 107 | |||
| 108 | (defun package-keyword-button-action (button) | ||
| 109 | "Show filtered \"*Packages*\" buffer for BUTTON. | ||
| 110 | The buffer is filtered by the `package-keyword' property of BUTTON. | ||
| 111 | Used for the `action' property of buttons in the buffer created by | ||
| 112 | `describe-package'." | ||
| 113 | (let ((pkg-keyword (button-get button 'package-keyword))) | ||
| 114 | (package-show-package-list t (list pkg-keyword)))) | ||
| 115 | |||
| 116 | (defun package-make-button (text &rest properties) | ||
| 117 | "Insert button labeled TEXT with button PROPERTIES at point. | ||
| 118 | PROPERTIES are passed to `insert-text-button', for which this | ||
| 119 | function is a convenience wrapper used by `describe-package-1'." | ||
| 120 | (let ((button-text (if (display-graphic-p) text (concat "[" text "]"))) | ||
| 121 | (button-face (if (display-graphic-p) | ||
| 122 | (progn | ||
| 123 | (require 'cus-edit) ; for the custom-button face | ||
| 124 | 'custom-button) | ||
| 125 | 'link))) | ||
| 126 | (apply #'insert-text-button button-text 'face button-face 'follow-link t | ||
| 127 | properties))) | ||
| 128 | |||
| 129 | (defun package--finder-goto-xref (button) | ||
| 130 | "Jump to a Lisp file for the BUTTON at point." | ||
| 131 | (let* ((file (button-get button 'xref)) | ||
| 132 | (lib (locate-library file))) | ||
| 133 | (if lib (finder-commentary lib) | ||
| 134 | (message "Unable to locate `%s'" file)))) | ||
| 135 | |||
| 136 | (define-button-type 'package--finder-xref 'action #'package--finder-goto-xref) | ||
| 137 | |||
| 138 | (defun describe-package-1 (pkg) | ||
| 139 | "Insert the package description for PKG. | ||
| 140 | Helper function for `describe-package'." | ||
| 141 | (require 'lisp-mnt) | ||
| 142 | (let* ((desc (or | ||
| 143 | (if (package-desc-p pkg) pkg) | ||
| 144 | (cadr (assq pkg package-alist)) | ||
| 145 | (let ((built-in (assq pkg package--builtins))) | ||
| 146 | (if built-in | ||
| 147 | (package--from-builtin built-in) | ||
| 148 | (cadr (assq pkg package-archive-contents)))))) | ||
| 149 | (name (if desc (package-desc-name desc) pkg)) | ||
| 150 | (pkg-dir (if desc (package-desc-dir desc))) | ||
| 151 | (reqs (if desc (package-desc-reqs desc))) | ||
| 152 | (required-by (if desc (package--used-elsewhere-p desc nil 'all))) | ||
| 153 | (version (if desc (package-desc-version desc))) | ||
| 154 | (archive (if desc (package-desc-archive desc))) | ||
| 155 | (extras (and desc (package-desc-extras desc))) | ||
| 156 | (website (cdr (assoc :url extras))) | ||
| 157 | (commit (cdr (assoc :commit extras))) | ||
| 158 | (keywords (if desc (package-desc--keywords desc))) | ||
| 159 | (built-in (eq pkg-dir 'builtin)) | ||
| 160 | (installable (and archive (not built-in))) | ||
| 161 | (status (if desc (package-desc-status desc) "orphan")) | ||
| 162 | (incompatible-reason (package--incompatible-p desc)) | ||
| 163 | (signed (if desc (package-desc-signed desc))) | ||
| 164 | (maintainers (or (cdr (assoc :maintainer extras)) | ||
| 165 | (cdr (assoc :maintainers extras)))) | ||
| 166 | (authors (cdr (assoc :authors extras))) | ||
| 167 | (news (and-let* (pkg-dir | ||
| 168 | ((not built-in)) | ||
| 169 | (file (expand-file-name "news" pkg-dir)) | ||
| 170 | ((file-regular-p file)) | ||
| 171 | ((file-readable-p file))) | ||
| 172 | file))) | ||
| 173 | (when (string= status "avail-obso") | ||
| 174 | (setq status "available obsolete")) | ||
| 175 | (when incompatible-reason | ||
| 176 | (setq status "incompatible")) | ||
| 177 | (princ (format "Package %S is %s.\n\n" name status)) | ||
| 178 | |||
| 179 | ;; TODO: Remove the string decorations and reformat the strings | ||
| 180 | ;; for future l10n. | ||
| 181 | (package--print-help-section "Status") | ||
| 182 | (cond (built-in | ||
| 183 | (insert (propertize (capitalize status) | ||
| 184 | 'font-lock-face 'package-status-built-in) | ||
| 185 | ".")) | ||
| 186 | (pkg-dir | ||
| 187 | (insert (propertize (if (member status '("unsigned" "dependency")) | ||
| 188 | "Installed" | ||
| 189 | (capitalize status)) | ||
| 190 | 'font-lock-face 'package-status-built-in)) | ||
| 191 | (insert (substitute-command-keys " in `")) | ||
| 192 | (let ((dir (abbreviate-file-name | ||
| 193 | (file-name-as-directory | ||
| 194 | (if (file-in-directory-p pkg-dir package-user-dir) | ||
| 195 | (file-relative-name pkg-dir package-user-dir) | ||
| 196 | pkg-dir))))) | ||
| 197 | (help-insert-xref-button dir 'help-package-def pkg-dir)) | ||
| 198 | (if (and (package-built-in-p name) | ||
| 199 | (not (package-built-in-p name version))) | ||
| 200 | (insert (substitute-command-keys | ||
| 201 | "',\n shadowing a ") | ||
| 202 | (propertize "built-in package" | ||
| 203 | 'font-lock-face 'package-status-built-in)) | ||
| 204 | (insert (substitute-quotes "'"))) | ||
| 205 | (if signed | ||
| 206 | (insert ".") | ||
| 207 | (insert " (unsigned).")) | ||
| 208 | (when (and (package-desc-p desc) | ||
| 209 | (not required-by) | ||
| 210 | (member status '("unsigned" "installed"))) | ||
| 211 | (insert " ") | ||
| 212 | (package-make-button "Delete" | ||
| 213 | 'action #'package-delete-button-action | ||
| 214 | 'package-desc desc))) | ||
| 215 | (incompatible-reason | ||
| 216 | (insert (propertize "Incompatible" 'font-lock-face 'font-lock-warning-face) | ||
| 217 | " because it depends on ") | ||
| 218 | (if (stringp incompatible-reason) | ||
| 219 | (insert "Emacs " incompatible-reason ".") | ||
| 220 | (insert "uninstallable packages."))) | ||
| 221 | (installable | ||
| 222 | (insert (capitalize status)) | ||
| 223 | (insert " from " (format "%s" archive)) | ||
| 224 | (insert " -- ") | ||
| 225 | (package-make-button | ||
| 226 | "Install" | ||
| 227 | 'action 'package-install-button-action | ||
| 228 | 'package-desc desc)) | ||
| 229 | (t (insert (capitalize status) "."))) | ||
| 230 | (insert "\n") | ||
| 231 | (unless (and pkg-dir (not archive)) ; Installed pkgs don't have archive. | ||
| 232 | (package--print-help-section "Archive" | ||
| 233 | (or archive "n/a"))) | ||
| 234 | (and version | ||
| 235 | (package--print-help-section "Version" | ||
| 236 | (package-version-join version))) | ||
| 237 | (when commit | ||
| 238 | (package--print-help-section "Commit" commit)) | ||
| 239 | (when desc | ||
| 240 | (package--print-help-section "Summary" | ||
| 241 | (package-desc-summary desc))) | ||
| 242 | |||
| 243 | (setq reqs (if desc (package-desc-reqs desc))) | ||
| 244 | (when reqs | ||
| 245 | (package--print-help-section "Requires") | ||
| 246 | (let ((first t)) | ||
| 247 | (dolist (req reqs) | ||
| 248 | (let* ((name (car req)) | ||
| 249 | (vers (cadr req)) | ||
| 250 | (text (format "%s-%s" (symbol-name name) | ||
| 251 | (package-version-join vers))) | ||
| 252 | (reason (if (and (listp incompatible-reason) | ||
| 253 | (assq name incompatible-reason)) | ||
| 254 | " (not available)" ""))) | ||
| 255 | (cond (first (setq first nil)) | ||
| 256 | ((>= (+ 2 (current-column) (length text) (length reason)) | ||
| 257 | (window-width)) | ||
| 258 | (insert ",\n ")) | ||
| 259 | (t (insert ", "))) | ||
| 260 | (help-insert-xref-button text 'help-package name) | ||
| 261 | (insert reason))) | ||
| 262 | (insert "\n"))) | ||
| 263 | (when required-by | ||
| 264 | (package--print-help-section "Required by") | ||
| 265 | (let ((first t)) | ||
| 266 | (dolist (pkg required-by) | ||
| 267 | (let ((text (package-desc-full-name pkg))) | ||
| 268 | (cond (first (setq first nil)) | ||
| 269 | ((>= (+ 2 (current-column) (length text)) | ||
| 270 | (window-width)) | ||
| 271 | (insert ",\n ")) | ||
| 272 | (t (insert ", "))) | ||
| 273 | (help-insert-xref-button text 'help-package | ||
| 274 | (package-desc-name pkg)))) | ||
| 275 | (insert "\n"))) | ||
| 276 | (when website | ||
| 277 | ;; Prefer https for the website of packages on common domains. | ||
| 278 | (when (string-match-p (rx bol "http://" (or "elpa." "www." "git." "") | ||
| 279 | (or "nongnu.org" "gnu.org" "sr.ht" | ||
| 280 | "emacswiki.org" "gitlab.com" "github.com") | ||
| 281 | "/") | ||
| 282 | website) | ||
| 283 | ;; But only if the user has "https" in `package-archives'. | ||
| 284 | (let ((gnu (cdr (assoc "gnu" package-archives)))) | ||
| 285 | (and gnu (string-match-p "^https" gnu) | ||
| 286 | (setq website | ||
| 287 | (replace-regexp-in-string "^http" "https" website))))) | ||
| 288 | (package--print-help-section "Website") | ||
| 289 | (help-insert-xref-button website 'help-url website) | ||
| 290 | (insert "\n")) | ||
| 291 | (when keywords | ||
| 292 | (package--print-help-section "Keywords") | ||
| 293 | (dolist (k keywords) | ||
| 294 | (package-make-button | ||
| 295 | k | ||
| 296 | 'package-keyword k | ||
| 297 | 'action 'package-keyword-button-action) | ||
| 298 | (insert " ")) | ||
| 299 | (insert "\n")) | ||
| 300 | (when maintainers | ||
| 301 | (unless (and (listp (car maintainers)) (listp (cdr maintainers))) | ||
| 302 | (setq maintainers (list maintainers))) | ||
| 303 | (package--print-help-section | ||
| 304 | (if (cdr maintainers) "Maintainers" "Maintainer")) | ||
| 305 | (dolist (maintainer maintainers) | ||
| 306 | (when (bolp) | ||
| 307 | (insert (make-string 13 ?\s))) | ||
| 308 | (package--print-email-button maintainer))) | ||
| 309 | (when authors | ||
| 310 | (package--print-help-section (if (cdr authors) "Authors" "Author")) | ||
| 311 | (dolist (author authors) | ||
| 312 | (when (bolp) | ||
| 313 | (insert (make-string 13 ?\s))) | ||
| 314 | (package--print-email-button author))) | ||
| 315 | (let* ((all-pkgs (append (cdr (assq name package-alist)) | ||
| 316 | (cdr (assq name package-archive-contents)) | ||
| 317 | (let ((bi (assq name package--builtins))) | ||
| 318 | (if bi (list (package--from-builtin bi)))))) | ||
| 319 | (other-pkgs (delete desc all-pkgs))) | ||
| 320 | (when other-pkgs | ||
| 321 | (package--print-help-section "Other versions" | ||
| 322 | (mapconcat (lambda (opkg) | ||
| 323 | (let* ((ov (package-desc-version opkg)) | ||
| 324 | (dir (package-desc-dir opkg)) | ||
| 325 | (from (or (package-desc-archive opkg) | ||
| 326 | (if (stringp dir) "installed" dir)))) | ||
| 327 | (if (not ov) (format "%s" from) | ||
| 328 | (format "%s (%s)" | ||
| 329 | (make-text-button (package-version-join ov) nil | ||
| 330 | 'font-lock-face 'link | ||
| 331 | 'follow-link t | ||
| 332 | 'action | ||
| 333 | (lambda (_button) | ||
| 334 | (describe-package opkg))) | ||
| 335 | from)))) | ||
| 336 | other-pkgs ", ") | ||
| 337 | "."))) | ||
| 338 | |||
| 339 | (insert "\n") | ||
| 340 | |||
| 341 | (let ((start-of-description (point))) | ||
| 342 | (if built-in | ||
| 343 | ;; For built-in packages, get the description from the | ||
| 344 | ;; Commentary header. | ||
| 345 | (insert (or (lm-commentary (locate-file (format "%s.el" name) | ||
| 346 | load-path | ||
| 347 | load-file-rep-suffixes)) | ||
| 348 | "")) | ||
| 349 | |||
| 350 | (if (package-installed-p desc) | ||
| 351 | ;; For installed packages, get the description from the | ||
| 352 | ;; installed files. | ||
| 353 | (insert (package--get-description desc)) | ||
| 354 | |||
| 355 | ;; For non-built-in, non-installed packages, get description from | ||
| 356 | ;; the archive. | ||
| 357 | (let* ((basename (format "%s-readme.txt" name)) | ||
| 358 | readme-string) | ||
| 359 | |||
| 360 | (package--with-response-buffer (package-archive-base desc) | ||
| 361 | :file basename :noerror t | ||
| 362 | (save-excursion | ||
| 363 | (goto-char (point-max)) | ||
| 364 | (unless (bolp) | ||
| 365 | (insert ?\n))) | ||
| 366 | (cl-assert (not enable-multibyte-characters)) | ||
| 367 | (setq readme-string | ||
| 368 | ;; The readme.txt files are defined to contain utf-8 text. | ||
| 369 | (decode-coding-region (point-min) (point-max) 'utf-8 t)) | ||
| 370 | t) | ||
| 371 | (insert (or readme-string | ||
| 372 | "This package does not provide a description."))))) | ||
| 373 | |||
| 374 | ;; Insert news if available. | ||
| 375 | (when news | ||
| 376 | (insert "\n" (make-separator-line) "\n" | ||
| 377 | (propertize "* News" 'face 'package-help-section-name) | ||
| 378 | "\n\n") | ||
| 379 | (insert-file-contents news)) | ||
| 380 | |||
| 381 | ;; Make library descriptions into links. | ||
| 382 | (goto-char start-of-description) | ||
| 383 | (package--describe-add-library-links) | ||
| 384 | ;; Make URLs in the description into links. | ||
| 385 | (goto-char start-of-description) | ||
| 386 | (browse-url-add-buttons)))) | ||
| 387 | |||
| 388 | ;;;###autoload | ||
| 389 | (defun describe-package (package) | ||
| 390 | "Display the full documentation of PACKAGE (a symbol)." | ||
| 391 | (interactive | ||
| 392 | (let* ((guess (or (function-called-at-point) | ||
| 393 | (symbol-at-point)))) | ||
| 394 | (require 'finder-inf nil t) | ||
| 395 | ;; Load the package list if necessary (but don't activate them). | ||
| 396 | (unless package--initialized | ||
| 397 | (package-initialize t)) | ||
| 398 | (let ((packages (append (mapcar #'car package-alist) | ||
| 399 | (mapcar #'car package-archive-contents) | ||
| 400 | (mapcar #'car package--builtins)))) | ||
| 401 | (unless (memq guess packages) | ||
| 402 | (setq guess nil)) | ||
| 403 | (setq packages (mapcar #'symbol-name packages)) | ||
| 404 | (let ((val | ||
| 405 | (completing-read (format-prompt "Describe package" guess) | ||
| 406 | packages nil t nil nil (when guess | ||
| 407 | (symbol-name guess))))) | ||
| 408 | (list (and (> (length val) 0) (intern val))))))) | ||
| 409 | (if (not (or (package-desc-p package) (and package (symbolp package)))) | ||
| 410 | (message "No package specified") | ||
| 411 | (help-setup-xref (list #'describe-package package) | ||
| 412 | (called-interactively-p 'interactive)) | ||
| 413 | (with-help-window (help-buffer) | ||
| 414 | (with-current-buffer standard-output | ||
| 415 | (describe-package-1 package))))) | ||
| 416 | |||
| 417 | |||
| 418 | (provide 'package-describe) | ||
| 419 | ;;; package-describe.el ends here | ||