diff options
Diffstat (limited to 'lisp/progmodes/project.el')
-rw-r--r-- | lisp/progmodes/project.el | 506 |
1 files changed, 433 insertions, 73 deletions
diff --git a/lisp/progmodes/project.el b/lisp/progmodes/project.el index f5f4092babf..0a15939d243 100644 --- a/lisp/progmodes/project.el +++ b/lisp/progmodes/project.el @@ -1,6 +1,11 @@ ;;; project.el --- Operations on the current project -*- lexical-binding: t; -*- ;; Copyright (C) 2015-2020 Free Software Foundation, Inc. +;; Version: 0.4.0 +;; Package-Requires: ((emacs "26.3")) + +;; This is a GNU ELPA :core package. Avoid using functionality that +;; not compatible with the version of Emacs recorded above. ;; This file is part of GNU Emacs. @@ -19,6 +24,11 @@ ;;; Commentary: +;; NOTE: The project API is still experimental and can change in major, +;; backward-incompatible ways. Everyone is encouraged to try it, and +;; report to us any problems or use cases we hadn't anticipated, by +;; sending an email to emacs-devel, or `M-x report-emacs-bug'. +;; ;; This file contains generic infrastructure for dealing with ;; projects, some utility functions, and commands using that ;; infrastructure. @@ -27,15 +37,10 @@ ;; current project, without having to know which package handles ;; detection of that project type, parsing its config files, etc. ;; -;; NOTE: The project API is still experimental and can change in major, -;; backward-incompatible ways. Everyone is encouraged to try it, and -;; report to us any problems or use cases we hadn't anticipated, by -;; sending an email to emacs-devel, or `M-x report-emacs-bug'. -;; ;; Infrastructure: ;; ;; Function `project-current', to determine the current project -;; instance, and 5 (at the moment) generic functions that act on it. +;; instance, and 4 (at the moment) generic functions that act on it. ;; This list is to be extended in future versions. ;; ;; Utils: @@ -72,9 +77,7 @@ ;; whole Emacs session, independent of the current directory. Or, ;; in the more advanced case, open a set of projects, and have some ;; project-related commands to use them all. E.g., have a command -;; to search for a regexp across all open projects. Provide a -;; history of projects that were opened in the past (storing it as a -;; list of directories should suffice). +;; to search for a regexp across all open projects. ;; ;; * Support for project-local variables: a UI to edit them, and a ;; utility function to retrieve a value. Probably useless without @@ -88,6 +91,12 @@ ;;; Code: (require 'cl-generic) +(eval-when-compile (require 'subr-x)) + +(defgroup project nil + "Operations on the current project." + :version "28.1" + :group 'tools) (defvar project-find-functions (list #'project-try-vc) "Special hook to find the project containing a given directory. @@ -95,36 +104,51 @@ Each functions on this hook is called in turn with one argument (the directory) and should return either nil to mean that it is not applicable, or a project instance.") +(defvar project-current-inhibit-prompt nil + "Non-nil to skip prompting the user in `project-current'.") + ;;;###autoload (defun project-current (&optional maybe-prompt dir) "Return the project instance in DIR or `default-directory'. When no project found in DIR, and MAYBE-PROMPT is non-nil, ask -the user for a different directory to look in. If that directory -is not a part of a detectable project either, return a -`transient' project instance rooted in it." +the user for a different project to look in." (unless dir (setq dir default-directory)) (let ((pr (project--find-in-directory dir))) (cond (pr) - (maybe-prompt - (setq dir (read-directory-name "Choose the project directory: " dir nil t) - pr (project--find-in-directory dir)) - (unless pr - (message "Using `%s' as a transient project root" dir) - (setq pr (cons 'transient dir))))) + ((unless project-current-inhibit-prompt + maybe-prompt) + (setq dir (project-prompt-project-dir) + pr (project--find-in-directory dir)))) + (when maybe-prompt + (if pr + (project--add-to-project-list-front pr) + (project--remove-from-project-list dir) + (setq pr (cons 'transient dir)))) pr)) (defun project--find-in-directory (dir) (run-hook-with-args-until-success 'project-find-functions dir)) -(cl-defgeneric project-roots (project) - "Return the list of directory roots of the current project. +(cl-defgeneric project-root (project) + "Return root directory of the current project. -Most often it's just one directory which contains the project -build file and everything else in the project. But in more -advanced configurations, a project can span multiple directories. +It usually contains the main build file, dependencies +configuration file, etc. Though neither is mandatory. -The directory names should be absolute.") +The directory name must be absolute." + (car (project-roots project))) + +(cl-defgeneric project-roots (project) + "Return the list containing the current project root. + +The function is obsolete, all projects have one main root anyway, +and the rest should be possible to express through +`project-external-roots'." + ;; FIXME: Can we specify project's version here? + ;; FIXME: Could we make this affect cl-defmethod calls too? + (declare (obsolete project-root "0.3.0")) + (list (project-root project))) ;; FIXME: Add MODE argument, like in `ede-source-paths'? (cl-defgeneric project-external-roots (_project) @@ -133,18 +157,14 @@ The directory names should be absolute.") It's the list of directories outside of the project that are still related to it. If the project deals with source code then, depending on the languages used, this list should include the -headers search path, load path, class path, and so on. - -The rule of thumb for whether to include a directory here, and -not in `project-roots', is whether its contents are meant to be -edited together with the rest of the project." +headers search path, load path, class path, and so on." nil) (cl-defgeneric project-ignores (_project _dir) "Return the list of glob patterns to ignore inside DIR. Patterns can match both regular files and directories. To root an entry, start it with `./'. To match directories only, -end it with `/'. DIR must be one of `project-roots' or +end it with `/'. DIR must be either `project-root' or one of `project-external-roots'." ;; TODO: Document and support regexp ignores as used by Hg. ;; TODO: Support whitelist entries. @@ -165,21 +185,22 @@ end it with `/'. DIR must be one of `project-roots' or (t (complete-with-action action all-files string pred))))) -(cl-defmethod project-roots ((project (head transient))) - (list (cdr project))) +(cl-defmethod project-root ((project (head transient))) + (cdr project)) (cl-defgeneric project-files (project &optional dirs) "Return a list of files in directories DIRS in PROJECT. DIRS is a list of absolute directories; it should be some -subset of the project roots and external roots. +subset of the project root and external roots. The default implementation uses `find-program'. PROJECT is used to find the list of ignores for each directory." - (cl-mapcan + (mapcan (lambda (dir) (project--files-in-directory dir (project--dir-ignores project dir))) - (or dirs (project-roots project)))) + (or dirs + (list (project-root project))))) (defun project--files-in-directory (dir ignores &optional files) (require 'find-dired) @@ -218,14 +239,24 @@ to find the list of ignores for each directory." local-files)))) (defgroup project-vc nil - "Project implementation using the VC package." + "Project implementation based on the VC package." :version "25.1" - :group 'tools) + :group 'project) (defcustom project-vc-ignores nil "List of patterns to include in `project-ignores'." :type '(repeat string) - :safe 'listp) + :safe #'listp) + +(defcustom project-vc-merge-submodules t + "Non-nil to consider submodules part of the parent project. + +After changing this variable (using Customize or .dir-locals.el) +you might have to restart Emacs to see the effect." + :type 'boolean + :version "28.1" + :package-version '(project . "0.2.0") + :safe #'booleanp) ;; FIXME: Using the current approach, major modes are supposed to set ;; this variable to a buffer-local value. So we don't have access to @@ -269,14 +300,47 @@ backend implementation of `project-external-roots'.") ('Git ;; Don't stop at submodule boundary. (or (vc-file-getprop dir 'project-git-root) - (vc-file-setprop dir 'project-git-root - (vc-find-root dir ".git/")))) + (let ((root (vc-call-backend backend 'root dir))) + (vc-file-setprop + dir 'project-git-root + (if (and + ;; FIXME: Invalidate the cache when the value + ;; of this variable changes. + (project--vc-merge-submodules-p root) + (project--submodule-p root)) + (let* ((parent (file-name-directory + (directory-file-name root)))) + (vc-call-backend backend 'root parent)) + root))))) ('nil nil) (_ (ignore-errors (vc-call-backend backend 'root dir)))))) (and root (cons 'vc root)))) -(cl-defmethod project-roots ((project (head vc))) - (list (cdr project))) +(defun project--submodule-p (root) + ;; XXX: We only support Git submodules for now. + ;; + ;; For submodules, at least, we expect the users to prefer them to + ;; be considered part of the parent project. For those who don't, + ;; there is the custom var now. + ;; + ;; Some users may also set up things equivalent to Git submodules + ;; using "git worktree" (for example). However, we expect that most + ;; of them would prefer to treat those as separate projects anyway. + (let* ((gitfile (expand-file-name ".git" root))) + (cond + ((file-directory-p gitfile) + nil) + ((with-temp-buffer + (insert-file-contents gitfile) + (goto-char (point-min)) + ;; Kind of a hack to distinguish a submodule from + ;; other cases of .git files pointing elsewhere. + (looking-at "gitdir: [./]+/\\.git/modules/")) + t) + (t nil)))) + +(cl-defmethod project-root ((project (head vc))) + (cdr project)) (cl-defmethod project-external-roots ((project (head vc))) (project-subtract-directories @@ -284,10 +348,10 @@ backend implementation of `project-external-roots'.") (mapcar #'file-name-as-directory (funcall project-vc-external-roots-function))) - (project-roots project))) + (list (project-root project)))) (cl-defmethod project-files ((project (head vc)) &optional dirs) - (cl-mapcan + (mapcan (lambda (dir) (let (backend) (if (and (file-equal-p dir (cdr project)) @@ -302,7 +366,8 @@ backend implementation of `project-external-roots'.") (project--files-in-directory dir (project--dir-ignores project dir))))) - (or dirs (project-roots project)))) + (or dirs + (list (project-root project))))) (declare-function vc-git--program-version "vc-git") (declare-function vc-git--run-command-string "vc-git") @@ -331,20 +396,23 @@ backend implementation of `project-external-roots'.") (split-string (apply #'vc-git--run-command-string nil "ls-files" args) "\0" t))) - ;; Unfortunately, 'ls-files --recurse-submodules' conflicts with '-o'. - (let* ((submodules (project--git-submodules)) - (sub-files - (mapcar - (lambda (module) - (when (file-directory-p module) - (project--vc-list-files - (concat default-directory module) - backend - extra-ignores))) - submodules))) - (setq files - (apply #'nconc files sub-files))) - files)) + (when (project--vc-merge-submodules-p default-directory) + ;; Unfortunately, 'ls-files --recurse-submodules' conflicts with '-o'. + (let* ((submodules (project--git-submodules)) + (sub-files + (mapcar + (lambda (module) + (when (file-directory-p module) + (project--vc-list-files + (concat default-directory module) + backend + extra-ignores))) + submodules))) + (setq files + (apply #'nconc files sub-files)))) + ;; 'git ls-files' returns duplicate entries for merge conflicts. + ;; XXX: Better solutions welcome, but this seems cheap enough. + (delete-consecutive-dups files))) (`Hg (let ((default-directory (expand-file-name (file-name-as-directory dir))) args) @@ -362,6 +430,11 @@ backend implementation of `project-external-roots'.") (lambda (s) (concat default-directory s)) (split-string (buffer-string) "\0" t))))))) +(defun project--vc-merge-submodules-p (dir) + (project--value-in-dir + 'project-vc-merge-submodules + dir)) + (defun project--git-submodules () ;; 'git submodule foreach' is much slower. (condition-case nil @@ -376,7 +449,7 @@ backend implementation of `project-external-roots'.") (cl-defmethod project-ignores ((project (head vc)) dir) (let* ((root (cdr project)) - backend) + backend) (append (when (file-equal-p dir root) (setq backend (vc-responsible-backend root)) @@ -417,6 +490,28 @@ DIRS must contain directory names." ;; Sidestep the issue of expanded/abbreviated file names here. (cl-set-difference files dirs :test #'file-in-directory-p)) + +;;; Project commands + +;;;###autoload +(defvar project-prefix-map + (let ((map (make-sparse-keymap))) + (define-key map "f" 'project-find-file) + (define-key map "b" 'project-switch-to-buffer) + (define-key map "s" 'project-shell) + (define-key map "d" 'project-dired) + (define-key map "v" 'project-vc-dir) + (define-key map "c" 'project-compile) + (define-key map "e" 'project-eshell) + (define-key map "k" 'project-kill-buffers) + (define-key map "p" 'project-switch-project) + (define-key map "g" 'project-find-regexp) + (define-key map "r" 'project-query-replace-regexp) + map) + "Keymap for project commands.") + +;;;###autoload (define-key ctl-x-map "p" project-prefix-map) + (defun project--value-in-dir (var dir) (with-temp-buffer (setq default-directory dir) @@ -443,7 +538,7 @@ requires quoting, e.g. `\\[quoted-insert]<space>'." (let* ((pr (project-current t)) (files (if (not current-prefix-arg) - (project-files pr (project-roots pr)) + (project-files pr) (let ((dir (read-directory-name "Base directory: " nil default-directory t))) (project--files-in-directory dir @@ -454,9 +549,8 @@ requires quoting, e.g. `\\[quoted-insert]<space>'." nil))) (defun project--dir-ignores (project dir) - (let* ((roots (project-roots project)) - (root (cl-find dir roots :test #'file-in-directory-p))) - (if (not root) + (let ((root (project-root project))) + (if (not (file-in-directory-p dir root)) (project-ignores nil nil) ;The defaults. (let ((ignores (project-ignores project root))) (if (file-equal-p root dir) @@ -474,8 +568,8 @@ pattern to search for." (require 'xref) (let* ((pr (project-current t)) (files - (project-files pr (append - (project-roots pr) + (project-files pr (cons + (project-root pr) (project-external-roots pr))))) (xref--show-xrefs (apply-partially #'project--find-regexp-in-files regexp files) @@ -513,23 +607,23 @@ pattern to search for." ;;;###autoload (defun project-find-file () - "Visit a file (with completion) in the current project's roots. + "Visit a file (with completion) in the current project. The completion default is the filename at point, if one is recognized." (interactive) (let* ((pr (project-current t)) - (dirs (project-roots pr))) + (dirs (list (project-root pr)))) (project-find-file-in (thing-at-point 'filename) dirs pr))) ;;;###autoload (defun project-or-external-find-file () - "Visit a file (with completion) in the current project's roots or external roots. + "Visit a file (with completion) in the current project or external roots. The completion default is the filename at point, if one is recognized." (interactive) (let* ((pr (project-current t)) - (dirs (append - (project-roots pr) + (dirs (cons + (project-root pr) (project-external-roots pr)))) (project-find-file-in (thing-at-point 'filename) dirs pr))) @@ -541,6 +635,7 @@ For the arguments list, see `project--read-file-cpd-relative'." (const :tag "Read with completion from absolute names" project--read-file-absolute) (function :tag "Custom function" nil)) + :group 'project :version "27.1") (defun project--read-file-cpd-relative (prompt @@ -577,9 +672,10 @@ PREDICATE, HIST, and DEFAULT have the same meaning as in (defun project-find-file-in (filename dirs project) "Complete FILENAME in DIRS in PROJECT and visit the result." (let* ((all-files (project-files project dirs)) + (completion-ignore-case read-file-name-completion-ignore-case) (file (funcall project-read-file-name-function - "Find file" all-files nil nil - filename))) + "Find file" all-files nil nil + filename))) (if (string= file "") (user-error "You didn't specify the file") (find-file file)))) @@ -605,6 +701,57 @@ PREDICATE, HIST, and DEFAULT have the same meaning as in collection predicate t res hist nil))) res)) +;;;###autoload +(defun project-dired () + "Start Dired in the current project's root." + (interactive) + (dired (project-root (project-current t)))) + +;;;###autoload +(defun project-vc-dir () + "Run VC-Dir in the current project's root." + (interactive) + (vc-dir (project-root (project-current t)))) + +;;;###autoload +(defun project-shell () + "Start an inferior shell in the current project's root directory. +If a buffer already exists for running a shell in the project's root, +switch to it. Otherwise, create a new shell buffer. +With \\[universal-argument] prefix arg, create a new inferior shell buffer even +if one already exists." + (interactive) + (let* ((default-directory (project-root (project-current t))) + (default-project-shell-name + (concat "*" (file-name-nondirectory + (directory-file-name + (file-name-directory default-directory))) + "-shell*")) + (shell-buffer (get-buffer default-project-shell-name))) + (if (and shell-buffer (not current-prefix-arg)) + (pop-to-buffer shell-buffer) + (shell (generate-new-buffer-name default-project-shell-name))))) + +;;;###autoload +(defun project-eshell () + "Start Eshell in the current project's root directory. +If a buffer already exists for running Eshell in the project's root, +switch to it. Otherwise, create a new Eshell buffer. +With \\[universal-argument] prefix arg, create a new Eshell buffer even +if one already exists." + (interactive) + (defvar eshell-buffer-name) + (let* ((default-directory (project-root (project-current t))) + (eshell-buffer-name + (concat "*" (file-name-nondirectory + (directory-file-name + (file-name-directory default-directory))) + "-eshell*")) + (eshell-buffer (get-buffer eshell-buffer-name))) + (if (and eshell-buffer (not current-prefix-arg)) + (pop-to-buffer eshell-buffer) + (eshell t)))) + (declare-function fileloop-continue "fileloop" ()) ;;;###autoload @@ -632,5 +779,218 @@ loop using the command \\[fileloop-continue]." from to (project-files (project-current t)) 'default) (fileloop-continue)) +(defvar compilation-read-command) +(declare-function compilation-read-command "compile") + +;;;###autoload +(defun project-compile (command &optional comint) + "Run `compile' in the project root. +Arguments the same as in `compile'." + (interactive + (list + (let ((command (eval compile-command))) + (if (or compilation-read-command current-prefix-arg) + (compilation-read-command command) + command)) + (consp current-prefix-arg))) + (let* ((pr (project-current t)) + (default-directory (project-root pr))) + (compile command comint))) + +;;;###autoload +(defun project-switch-to-buffer () + "Switch to another buffer that is related to the current project. +A buffer is related to a project if its `default-directory' +is inside the directory hierarchy of the project's root." + (interactive) + (let* ((root (project-root (project-current t))) + (current-buffer (current-buffer)) + (other-buffer (other-buffer current-buffer)) + (other-name (buffer-name other-buffer)) + (predicate + (lambda (buffer) + ;; BUFFER is an entry (BUF-NAME . BUF-OBJ) of Vbuffer_alist. + (and (cdr buffer) + (not (eq (cdr buffer) current-buffer)) + (when-let ((file (buffer-local-value 'default-directory + (cdr buffer)))) + (file-in-directory-p file root)))))) + (switch-to-buffer + (read-buffer + "Switch to buffer: " + (when (funcall predicate (cons other-name other-buffer)) + other-name) + nil + predicate)))) + +(defcustom project-kill-buffers-ignores + '("\\*Help\\*") + "Conditions for buffers `project-kill-buffers' should not kill. +Each condition is either a regular expression matching a buffer +name, or a predicate function that takes a buffer object as +argument and returns non-nil if it matches. Buffers that match +any of the conditions will not be killed." + :type '(repeat (choice regexp function)) + :version "28.1" + :package-version '(project . "0.5.0")) + +(defun project--buffer-list (pr) + "Return the list of all buffers in project PR." + (let ((root (project-root pr)) + bufs) + (dolist (buf (buffer-list)) + (let ((filename (or (buffer-file-name buf) + (buffer-local-value 'default-directory buf)))) + (when (and filename (file-in-directory-p filename root)) + (push buf bufs)))) + (nreverse bufs))) + +;;;###autoload +(defun project-kill-buffers () + "Kill all live buffers belonging to the current project. +Certain buffers may be \"spared\", see `project-kill-buffers-ignores'." + (interactive) + (let ((pr (project-current t)) bufs) + (dolist (buf (project--buffer-list pr)) + (unless (seq-some + (lambda (c) + (cond ((stringp c) + (string-match-p c (buffer-name buf))) + ((functionp c) + (funcall c buf)))) + project-kill-buffers-ignores) + (push buf bufs))) + (when (yes-or-no-p (format "Kill %d buffers in %s? " + (length bufs) (project-root pr))) + (mapc #'kill-buffer bufs)))) + + +;;; Project list + +(defcustom project-list-file (locate-user-emacs-file "projects") + "File in which to save the list of known projects." + :type 'file + :version "28.1" + :group 'project) + +(defvar project--list 'unset + "List structure containing root directories of known projects. +With some possible metadata (to be decided).") + +(defun project--read-project-list () + "Initialize `project--list' using contents of `project-list-file'." + (let ((filename project-list-file)) + (setq project--list + (when (file-exists-p filename) + (with-temp-buffer + (insert-file-contents filename) + (read (current-buffer))))) + (unless (seq-every-p + (lambda (elt) (stringp (car-safe elt))) + project--list) + (warn "Contents of %s are in wrong format, resetting" + project-list-file) + (setq project--list nil)))) + +(defun project--ensure-read-project-list () + "Initialize `project--list' if it isn't already initialized." + (when (eq project--list 'unset) + (project--read-project-list))) + +(defun project--write-project-list () + "Save `project--list' in `project-list-file'." + (let ((filename project-list-file)) + (with-temp-buffer + (insert ";;; -*- lisp-data -*-\n") + (pp project--list (current-buffer)) + (write-region nil nil filename nil 'silent)))) + +(defun project--add-to-project-list-front (pr) + "Add project PR to the front of the project list. +Save the result in `project-list-file' if the list of projects has changed." + (project--ensure-read-project-list) + (let ((dir (project-root pr))) + (unless (equal (caar project--list) dir) + (setq project--list (assoc-delete-all dir project--list)) + (push (list dir) project--list) + (project--write-project-list)))) + +(defun project--remove-from-project-list (pr-dir) + "Remove directory PR-DIR of a missing project from the project list. +If the directory was in the list before the removal, save the +result in `project-list-file'. Announce the project's removal +from the list." + (project--ensure-read-project-list) + (when (assoc pr-dir project--list) + (setq project--list (assoc-delete-all pr-dir project--list)) + (message "Project `%s' not found; removed from list" pr-dir) + (project--write-project-list))) + +(defun project-prompt-project-dir () + "Prompt the user for a directory that is one of the known project roots. +The project is chosen among projects known from the project list, +see `project-list-file'. +It's also possible to enter an arbitrary directory not in the list." + (project--ensure-read-project-list) + (let* ((dir-choice "... (choose a dir)") + (choices + ;; XXX: Just using this for the category (for the substring + ;; completion style). + (project--file-completion-table + (append project--list `(,dir-choice)))) + (pr-dir (completing-read "Select project: " choices nil t))) + (if (equal pr-dir dir-choice) + (read-directory-name "Select directory: " default-directory nil t) + pr-dir))) + +;;;###autoload +(defun project-known-project-roots () + "Return the list of root directories of all known projects." + (project--ensure-read-project-list) + (mapcar #'car project--list)) + + +;;; Project switching + +;;;###autoload +(defvar project-switch-commands + '((?f "Find file" project-find-file) + (?g "Find regexp" project-find-regexp) + (?d "Dired" project-dired) + (?v "VC-Dir" project-vc-dir) + (?e "Eshell" project-eshell)) + "Alist mapping keys to project switching menu entries. +Used by `project-switch-project' to construct a dispatch menu of +commands available upon \"switching\" to another project. + +Each element is of the form (KEY LABEL COMMAND), where COMMAND is the +command to run when KEY is pressed. LABEL is used to distinguish +the menu entries in the dispatch menu.") + +(defun project--keymap-prompt () + "Return a prompt for the project swithing dispatch menu." + (mapconcat + (pcase-lambda (`(,key ,label)) + (format "[%s] %s" + (propertize (key-description `(,key)) 'face 'bold) + label)) + project-switch-commands + " ")) + +;;;###autoload +(defun project-switch-project () + "\"Switch\" to another project by running an Emacs command. +The available commands are presented as a dispatch menu +made from `project-switch-commands'." + (interactive) + (let ((dir (project-prompt-project-dir)) + (choice nil)) + (while (not choice) + (setq choice (assq (read-event (project--keymap-prompt)) + project-switch-commands))) + (let ((default-directory dir) + (project-current-inhibit-prompt t)) + (call-interactively (nth 2 choice))))) + (provide 'project) ;;; project.el ends here |