diff --git a/CHANGELOG.md b/CHANGELOG.md index 851f13971..8ac1987d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### New features +- [#2158](https://github.com/bbatsov/projectile/issues/2158): `projectile-run-task` now also offers the recipes of the modules a justfile declares with `mod` (and `mod?`), qualified the way just names them (`just:foo::build`), including nested modules and modules with an explicit path. - [#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/tasks.adoc b/doc/modules/ROOT/pages/tasks.adoc index 7e07af5e2..a5348a409 100644 --- a/doc/modules/ROOT/pages/tasks.adoc +++ b/doc/modules/ROOT/pages/tasks.adoc @@ -188,8 +188,8 @@ image::projectile-discovered-tasks.png[projectile-run-task offering npm scripts | `composer:lint` | just recipes -| `justfile`, `.justfile`, `Justfile` -| `just:build` +| `justfile`, `.justfile`, `Justfile`, plus the modules it declares with `mod` +| `just:build`, `just:foo::build` | go-task tasks | `Taskfile.yml` and friends @@ -216,6 +216,11 @@ completion prompt. Tasks are qualified with the `namespace` blocks they sit in `Gemfile`. A task whose name is computed at runtime (`task type, [:id]`) can't be known without running rake, so it isn't listed. +The recipes of a just module are qualified with its path the way just itself +names them (`just:foo::build` runs `just foo::build`). Modules are found where +just looks for them - `foo.just`, or `mod.just` or a justfile in the `foo` +directory - or at the path given in the `mod` statement. + Set `projectile-discover-tasks` to nil to turn this off, or customize `projectile-task-providers` to add your own. A provider is a function taking the project root and returning an alist of `(TASK-NAME . COMMAND)`; one that diff --git a/projectile.el b/projectile.el index 11abeb772..13fd007e1 100644 --- a/projectile.el +++ b/projectile.el @@ -12141,17 +12141,76 @@ duplicates dropped." (push name names)))))) (nreverse names))) +(defun projectile--just-modules (file) + "Return the modules the justfile FILE declares, as (NAME . PATH) pairs. +PATH is the module's explicit source path, or nil when it has none." + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + (let ((case-fold-search nil) + modules) + ;; `mod NAME' or `mod? NAME', optionally followed by a quoted path + ;; and a comment. Anchoring at the end of the line keeps a recipe + ;; called `mod' that takes a parameter from reading as a module. + (while (re-search-forward + (concat "^mod\\??[ \t]+\\([a-zA-Z_][a-zA-Z0-9_-]*\\)" + "\\(?:[ \t]+\\(?:'\\([^'\n]*\\)'\\|\"\\([^\"\n]*\\)\"\\)\\)?" + "[ \t]*\\(?:#.*\\)?$") + nil t) + (push (cons (match-string 1) (or (match-string 2) (match-string 3))) + modules)) + (nreverse modules)))) + +(defun projectile--just-module-file (directory name path) + "Return the source file of the just module NAME declared in DIRECTORY. +PATH is the module's explicit path, or nil. Like just itself, look for +NAME.just, then for `mod.just' or a justfile in the NAME directory; an +explicit PATH that names a directory is searched the same way." + (let* ((names (cons "mod.just" projectile--justfile-names)) + (candidates + (if path + (let ((file (expand-file-name path directory))) + (if (file-directory-p file) + (mapcar (lambda (name) (expand-file-name name file)) names) + (list file))) + (cons (expand-file-name (concat name ".just") directory) + (let ((module-directory (expand-file-name name directory))) + (mapcar (lambda (name) (expand-file-name name module-directory)) + names)))))) + (seq-find (lambda (file) + (and (file-readable-p file) (not (file-directory-p file)))) + candidates))) + +(defun projectile--just-recipes (file &optional prefix seen) + "Return the recipes of the justfile FILE and of the modules it declares. +The recipes of a module are qualified with its path the way just names +them (`foo::build'). PREFIX is the module path of FILE itself, and SEEN +lists the files already read, so a module that loops back is skipped." + (let ((seen (cons file seen)) + (directory (file-name-directory file))) + (append + ;; A recipe starts in column zero, may be marked quiet with `@' and + ;; may take parameters. The lookahead keeps `:=', which is an + ;; assignment, from reading as a recipe. + (mapcar (lambda (recipe) (concat prefix recipe)) + (projectile--matches-in-file + file "^@?\\([a-zA-Z_][a-zA-Z0-9_-]*\\)[^:\n]*:\\(?:[^=\n]\\|$\\)")) + (mapcan (lambda (module) + (when-let* ((module-file (projectile--just-module-file + directory (car module) (cdr module))) + ((not (member module-file seen)))) + (projectile--just-recipes module-file + (concat prefix (car module) "::") + seen))) + (projectile--just-modules file))))) + (defun projectile-tasks-from-just (project-root) - "Return the recipes of the justfile in PROJECT-ROOT as tasks." + "Return the recipes of the justfile in PROJECT-ROOT as tasks. +The recipes of the modules it declares with `mod' are included too, +qualified with the module's name (`just:foo::build')." (when-let* ((file (projectile--first-task-file project-root projectile--justfile-names))) - ;; A recipe starts in column zero, may be marked quiet with `@' and - ;; may take parameters. The lookahead keeps `:=', which is an - ;; assignment, from reading as a recipe. - (projectile--tasks-named - (projectile--matches-in-file - file "^@?\\([a-zA-Z_][a-zA-Z0-9_-]*\\)[^:\n]*:\\(?:[^=\n]\\|$\\)") - "just" "just %s"))) + (projectile--tasks-named (projectile--just-recipes file) "just" "just %s"))) (defun projectile--taskfile-task-names (file) "Return the keys of the top-level `tasks:' mapping in the Taskfile FILE. diff --git a/test/projectile-tasks-test.el b/test/projectile-tasks-test.el index 9d959ae06..e942dd72c 100644 --- a/test/projectile-tasks-test.el +++ b/test/projectile-tasks-test.el @@ -162,7 +162,39 @@ test filter=\"\": (it "does not mistake an assignment for a recipe" (projectile-test-with-project (("justfile" . "export FOO := \"bar\"\n")) - (expect (projectile-tasks-from-just (projectile-project-root)) :to-be nil)))) + (expect (projectile-tasks-from-just (projectile-project-root)) :to-be nil))) + + (it "reads the recipes of the modules a justfile declares" + (projectile-test-with-project + (("justfile" . "mod foo +mod? bar 'tools/bar.just' +[private] +mod baz # nested + +default: + @just --list +") + ("foo.just" . "build:\n echo foo\n") + ("tools/bar.just" . "lint:\n echo bar\n") + ("baz/mod.just" . "mod qux\n\ntest:\n echo baz\n") + ("baz/qux/justfile" . "run:\n echo qux\n")) + (expect (projectile-tasks-from-just (projectile-project-root)) + :to-equal '(("just:default" . "just default") + ("just:foo::build" . "just foo::build") + ("just:bar::lint" . "just bar::lint") + ("just:baz::test" . "just baz::test") + ("just:baz::qux::run" . "just baz::qux::run"))))) + + (it "skips a module without a source file and a module that loops back" + (projectile-test-with-project + (("justfile" . "mod? missing\nmod self 'justfile'\n\nbuild:\n cargo build\n")) + (expect (projectile-tasks-from-just (projectile-project-root)) + :to-equal '(("just:build" . "just build"))))) + + (it "does not mistake a recipe called mod for a module" + (projectile-test-with-project (("justfile" . "mod target:\n echo {{target}}\n")) + (expect (projectile-tasks-from-just (projectile-project-root)) + :to-equal '(("just:mod" . "just mod")))))) (describe "projectile-tasks-from-taskfile" (it "reads the keys of the tasks mapping"