diff --git a/CHANGELOG.md b/CHANGELOG.md index 851f13971..97a6c0e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### New features +- [#2200](https://github.com/bbatsov/projectile/pull/2200): Add `projectile-dirconfig-parsers`, an alist of configuration file names and parser functions consulted when a project root has no `projectile-dirconfig-file`, so the ignore/keep/ensure settings can be sourced from another tool's configuration file, e.g. an Eclipse `.project` XML. Custom parsers can delegate to the new `projectile-read-dirconfig-file`. - [#2182](https://github.com/bbatsov/projectile/pull/2182): Add `projectile-todos-in-projects` and `projectile-multi-occur-in-projects`, so every sibling command now has a public generic taking any list of projects behind it and a command for a group of your own is a two-line wrapper for all five. - [#2181](https://github.com/bbatsov/projectile/pull/2181): Sapling and Bazaar projects can now list their own ignored files, via the new `projectile-sapling-ignored-command` and `projectile-bzr-ignored-command`. - [#2180](https://github.com/bbatsov/projectile/pull/2180): Every shell backend can now open in another window (`s-p x 4 `), not just vterm, eat and ghostel: `shell`, `eshell`, `ielm`, `term` and the backend-dispatching `projectile-run` gained `-other-window` commands. diff --git a/doc/modules/ROOT/pages/ignoring.adoc b/doc/modules/ROOT/pages/ignoring.adoc index 881ee4478..c33342855 100644 --- a/doc/modules/ROOT/pages/ignoring.adoc +++ b/doc/modules/ROOT/pages/ignoring.adoc @@ -213,6 +213,30 @@ The same is true of the `+`, `-`, and `!` prefixes: leading spaces and tabs before the prefix are skipped, so accidental indentation won't silently turn the entry into a literal ignore pattern. +=== Sourcing the settings from another configuration file + +When a project has no `.projectile` file, `projectile-dirconfig-parsers` lets +you source the ignore/keep/ensure settings from a configuration file of +another tool instead. It's an alist of file names and parser functions; the +parser of the first file found in the project root is called with the file's +absolute path and must return a `projectile-dirconfig` structure (or nil when +the file holds no configuration): + +[source,elisp] +---- +(setq projectile-dirconfig-parsers + '((".project" . my-eclipse-project-parser))) + +(defun my-eclipse-project-parser (path) + "Parse the Eclipse XML at PATH into a `projectile-dirconfig'." + ...) +---- + +Parsers that only need to fall back to the built-in syntax can delegate to +`projectile-read-dirconfig-file`. A `.projectile` file always takes +precedence when it exists, and recognizing a configuration file as a project +root marker is governed separately by `projectile-project-root-files`. + == Ignored files using the project indexing tools If you're using the `hybrid` or `alien` indexing strategies, the simplest diff --git a/projectile.el b/projectile.el index 11abeb772..0a1e1dd9e 100644 --- a/projectile.el +++ b/projectile.el @@ -665,6 +665,39 @@ such line, listing the offending entries." :type 'boolean :package-version '(projectile . "3.0.0")) +(defcustom projectile-dirconfig-parsers nil + "Alist of project configuration files and their parser functions. + +Each element is a cons (FILE-NAME . PARSER). When a project root +doesn't contain `projectile-dirconfig-file', Projectile looks for +the first FILE-NAME from this list that exists in the root and +calls PARSER with the file's absolute path. PARSER must return a +`projectile-dirconfig' structure, or nil when the file turns out +to hold no configuration. Results are cached per project root and +invalidated when the parsed file's modification time changes, just +like the built-in dirconfig handling. + +This makes it possible to source a project's ignore/keep/ensure +settings from a configuration file of another tool, e.g. an +Eclipse `.project' XML file: + + (setq projectile-dirconfig-parsers + '((\".project\" . my-eclipse-project-parser))) + + (defun my-eclipse-project-parser (path) + \"Parse the Eclipse XML at PATH into a `projectile-dirconfig'.\" + ...) + +Custom parsers that only need to fall back to the built-in syntax +can delegate to `projectile-read-dirconfig-file'. Note that +`projectile-dirconfig-file' takes precedence when it exists, and +that treating a configuration file as a project root marker is +governed separately by `projectile-project-root-files'." + :group 'projectile + :type '(alist :key-type (string :tag "Configuration file name") + :value-type (function :tag "Parser function")) + :package-version '(projectile . "3.5.0")) + ;; The options below hold nothing but a list of names, patterns or regexps, ;; and none of them can do more than widen or narrow a listing - so a project ;; is free to set any of them from its .dir-locals.el. @@ -2593,16 +2626,17 @@ is tracked in `projectile--current-project'." When the files are cached in memory, what counts is when that cache was filled; otherwise it's the persistent cache file, if there is one. With no cache at all there is nothing to be stale." - (let ((dirconfig (projectile-dirconfig-file project-root)) - (cache-time (gethash project-root projectile-projects-cache-time))) + (let* ((dirconfig (car (projectile--dirconfig-entry project-root))) + (cache-time (gethash project-root projectile-projects-cache-time))) (if cache-time ;; The cache time is in whole seconds, so an edit in the second the ;; cache was filled counts as newer: re-indexing once too often ;; beats serving a file list that ignores the edit. - (when-let* ((attributes (file-attributes dirconfig))) + (when-let* ((attributes (and dirconfig (file-attributes dirconfig)))) (time-less-p cache-time (file-attribute-modification-time attributes))) (let ((cache-file (projectile-project-cache-file project-root))) - (and (file-exists-p cache-file) + (and dirconfig + (file-exists-p cache-file) (file-newer-than-file-p dirconfig cache-file)))))) (defun projectile-maybe-invalidate-cache (force) @@ -4919,12 +4953,13 @@ are accepted for backward compatibility but recorded separately so callers can flag the deprecated syntax. All slots default to nil." (keep nil) (ignore nil) (ensure nil) (prefixless-ignore nil)) -(defun projectile--maybe-warn-glob-keep-entries (project-root cfg) +(defun projectile--maybe-warn-glob-keep-entries (project-root dirconfig cfg) "Warn once per session about glob patterns in + keep entries. -PROJECT-ROOT identifies the warned-projects set; CFG is the parsed -`projectile-dirconfig' struct. The `+' prefix is for subdirectories -only; the parser silently coerces each entry to a directory, so a -glob pattern would never match." +PROJECT-ROOT identifies the warned-projects set, DIRCONFIG is the +configuration file the CFG `projectile-dirconfig' was parsed from. +The `+' prefix is for subdirectories only; the parser silently +coerces each entry to a directory, so a glob pattern would never +match." (when (and cfg (not (gethash project-root projectile--glob-keep-warned-projects))) (when-let* ((globbed (seq-filter @@ -4937,7 +4972,7 @@ glob pattern would never match." The `+' prefix is for subdirectory paths only; globs are not expanded \ and the entries are silently coerced to directory names. Use a plain \ directory or move the pattern to a `-'/`!' rule." - (expand-file-name projectile-dirconfig-file project-root) + dirconfig (mapconcat (lambda (s) (format "`%s'" s)) globbed ", ")) :warning)))) @@ -4979,20 +5014,44 @@ compatibility but are tracked separately so callers can warn." :ensure (nreverse ensure) :prefixless-ignore (nreverse prefixless)))) +(defun projectile--dirconfig-entry (&optional root) + "Return ROOT's dirconfig entry as (PATH . PARSER), or nil. +The built-in `projectile-dirconfig-file' is checked first; when it +doesn't exist, `projectile-dirconfig-parsers' is consulted in +order for the first configuration file present in the root. ROOT +defaults to the current project's root." + (let* ((project-root (or root (projectile-project-root))) + (builtin (projectile-dirconfig-file project-root))) + (if (projectile-file-exists-p builtin) + (cons builtin #'projectile-read-dirconfig-file) + (cl-some (lambda (entry) + (let ((path (expand-file-name (car entry) project-root))) + (and (projectile-file-exists-p path) + (cons path (cdr entry))))) + projectile-dirconfig-parsers)))) + +(defun projectile-read-dirconfig-file (path) + "Parse the dirconfig file at PATH into a `projectile-dirconfig'. +Return nil when PATH doesn't exist. This is the parser used for +the built-in `projectile-dirconfig-file'; it is exposed so custom +`projectile-dirconfig-parsers' entries can delegate to it." + (when (projectile-file-exists-p path) + (projectile--parse-dirconfig-string + (with-temp-buffer + (insert-file-contents path) + (buffer-string))))) + (defun projectile--parse-dirconfig-file-uncached (&optional root) "Parse ROOT's dirconfig file without caching. Return a `projectile-dirconfig' or nil if the file doesn't exist. ROOT defaults to the current project." - (let ((dirconfig (projectile-dirconfig-file root))) - (when (projectile-file-exists-p dirconfig) - (projectile--parse-dirconfig-string - (with-temp-buffer - (insert-file-contents dirconfig) - (buffer-string)))))) - -(defun projectile--maybe-warn-prefixless-entries (project-root cfg) + (when-let* ((entry (projectile--dirconfig-entry root))) + (funcall (cdr entry) (car entry)))) + +(defun projectile--maybe-warn-prefixless-entries (project-root dirconfig cfg) "Warn once per session about prefix-less ignore entries in CFG for PROJECT-ROOT. -CFG is a `projectile-dirconfig' struct." +DIRCONFIG is the configuration file CFG was parsed from; CFG is a +`projectile-dirconfig' struct." (when (and projectile-warn-on-prefixless-dirconfig-lines cfg (projectile-dirconfig-prefixless-ignore cfg) @@ -5003,10 +5062,10 @@ CFG is a `projectile-dirconfig' struct." 'projectile (format "%s contains entries without a `+'/`-'/`!' prefix: %s. \ The implicit form is treated as an ignore rule for backward \ -compatibility but is being phased out — please prefix the lines \ +compatibility but is being phased out - please prefix the lines \ explicitly. Set `projectile-warn-on-prefixless-dirconfig-lines' \ to nil to silence this warning." - (expand-file-name projectile-dirconfig-file project-root) + dirconfig (mapconcat (lambda (s) (format "`%s'" s)) (projectile-dirconfig-prefixless-ignore cfg) ", ")) @@ -5036,9 +5095,14 @@ is non-nil, lines whose first non-whitespace character matches it are treated as comments. Results are cached per project root and invalidated when the -dirconfig file's modification time changes." +dirconfig file's modification time changes. + +When the root has no `projectile-dirconfig-file', the parser for +the first configuration file from `projectile-dirconfig-parsers' +found there is used instead." (let* ((project-root (or root (projectile-project-root))) - (dirconfig (projectile-dirconfig-file project-root)) + (entry (projectile--dirconfig-entry project-root)) + (dirconfig (car entry)) (cached (gethash project-root projectile--dirconfig-cache)) (attrs (file-attributes dirconfig)) (mtime (when attrs (file-attribute-modification-time attrs))) @@ -5053,8 +5117,8 @@ dirconfig file's modification time changes." (list dirconfig mtime parsed) projectile--dirconfig-cache)) parsed))))) - (projectile--maybe-warn-prefixless-entries project-root result) - (projectile--maybe-warn-glob-keep-entries project-root result) + (projectile--maybe-warn-prefixless-entries project-root dirconfig result) + (projectile--maybe-warn-glob-keep-entries project-root dirconfig result) result)) ;;; Path expansion and completion diff --git a/test/projectile-ignore-test.el b/test/projectile-ignore-test.el index 316d23383..c2b28379e 100644 --- a/test/projectile-ignore-test.el +++ b/test/projectile-ignore-test.el @@ -715,4 +715,60 @@ (dolist (vcs '(jj fossil darcs pijul none)) (expect (projectile-get-ext-ignored-command vcs) :to-be nil)))) +(describe "projectile-dirconfig-parsers" + (before-each + (clrhash projectile--dirconfig-cache) + (clrhash projectile--glob-keep-warned-projects) + (clrhash projectile--prefixless-dirconfig-warned-projects)) + (it "prefers .projectile over custom configuration files" + (projectile-test-with-sandbox + (projectile-test-with-files ("project/") + (with-temp-file "project/.projectile" + (insert "-exclude\n")) + (with-temp-file "project/.project" + (insert "\n")) + (let* ((custom-called nil) + (projectile-dirconfig-parsers + (list (cons ".project" + (lambda (_path) + (setq custom-called t) + (make-projectile-dirconfig :keep '("custom/"))))))) + (expect (projectile-parse-dirconfig-file (expand-file-name "project/")) + :to-equal (make-projectile-dirconfig :ignore '("exclude"))) + (expect custom-called :to-be nil))))) + (it "uses the first custom parser whose file exists" + (projectile-test-with-sandbox + (projectile-test-with-files ("project/") + (with-temp-file "project/.project" + (insert "\n")) + (let* ((seen-path nil) + (projectile-dirconfig-parsers + (list (cons ".missing" #'ignore) + (cons ".project" + (lambda (path) + (setq seen-path path) + (make-projectile-dirconfig :ignore '("xml-source"))))))) + (expect (projectile-parse-dirconfig-file (expand-file-name "project/")) + :to-equal (make-projectile-dirconfig :ignore '("xml-source"))) + (expect seen-path + :to-equal (expand-file-name ".project" (expand-file-name "project/"))))))) + (it "returns nil when no configuration file exists" + (projectile-test-with-sandbox + (projectile-test-with-files ("project/") + (let ((projectile-dirconfig-parsers + (list (cons ".project" + (lambda (_path) + (make-projectile-dirconfig :keep '("custom/"))))))) + (expect (projectile-parse-dirconfig-file (expand-file-name "project/")) + :to-be nil))))) + (it "reads the built-in syntax via projectile-read-dirconfig-file" + (projectile-test-with-sandbox + (projectile-test-with-files ("project/") + (with-temp-file "project/custom.txt" + (insert "+keep-me\n-drop-me\n")) + (expect (projectile-read-dirconfig-file (expand-file-name "project/custom.txt")) + :to-equal (make-projectile-dirconfig + :keep '("keep-me/") + :ignore '("drop-me"))))))) + ;;; projectile-ignore-test.el ends here