diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-03 21:17:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-03 21:17:30 -0500 |
| commit | 2f8d8989856073cbed5f9159d02089903bc5343e (patch) | |
| tree | 1ab81336390846105c92debcc8e788d0d5905e22 | |
| parent | 31edc86a54d20c3c73d0ebad247fde2c35e6a964 (diff) | |
| download | dotemacs-2f8d8989856073cbed5f9159d02089903bc5343e.tar.gz dotemacs-2f8d8989856073cbed5f9159d02089903bc5343e.zip | |
refactor: defer projectile revert advice until projectile loads
`dev-fkeys.el` was wiring its three Projectile cache-revert advices via top-level `advice-add` calls using `apply-partially #'cj/--projectile-around-revert <map-symbol>`. That had three problems. The advice values were anonymous closures, so `advice-member-p` couldn't find them and a re-load would silently double-install. The implicit dependency on Projectile was load-ordered by accident. If `dev-fkeys.el` happened to require before Projectile loaded, the advice still attached to unbound symbols. And a fresh batch require of `dev-fkeys.el` for tests would always force the advice attempt regardless of whether Projectile was around.
I gave each Projectile target a named advice wrapper (`cj/--projectile-compile-around-revert`, `cj/--projectile-test-around-revert`, `cj/--projectile-run-around-revert`) and put the (target . advice) pairs in a `cj/--projectile-revert-advice-specs` defconst. `cj/--projectile-install-revert-advice` walks the specs, checks `fboundp` plus `advice-member-p`, and only adds advice that's missing. The installer is idempotent on reload, and the named wrappers make it easy to tear down later by symbol name.
`cj/--projectile-register-revert-advice` is the entry point at module load time. It installs immediately when Projectile is already a `featurep`, otherwise it schedules the installer through `eval-after-load 'projectile`. Either way the advice is in place once Projectile is available, and `dev-fkeys.el` no longer relies on a particular load order.
Tests in the new `tests/test-dev-fkeys--projectile-advice-install.el` cover four cases. Registration defers via `eval-after-load` when Projectile isn't a feature yet. Registration installs immediately when it is. Install skips unbound Projectile functions. Install advises each bound Projectile command runner with the matching named wrapper. 23 projectile-related tests pass together.
| -rw-r--r-- | modules/dev-fkeys.el | 64 | ||||
| -rw-r--r-- | tests/test-dev-fkeys--projectile-advice-install.el | 86 |
2 files changed, 124 insertions, 26 deletions
diff --git a/modules/dev-fkeys.el b/modules/dev-fkeys.el index 0b973470..c9a5fc13 100644 --- a/modules/dev-fkeys.el +++ b/modules/dev-fkeys.el @@ -179,11 +179,6 @@ a single Compile entry that calls plain `compile'." ;; buffer-local compilation finish hook so overlapping compiles cannot ;; overwrite or consume one another's revert metadata. -(defvar cj/--projectile-revert-state nil - "Legacy dynamic state used by direct tests of `cj/--projectile-revert-on-fail'. -The around advice no longer stores live compile metadata here; it closes -over the plist returned by `cj/--projectile-capture-cmd' instead.") - (defun cj/--projectile-capture-cmd (map-symbol) "Capture the cached cmd at the project root in MAP-SYMBOL. MAP-SYMBOL is the symbol of a projectile cmd-map (e.g. @@ -210,18 +205,6 @@ cache alone." (not (equal prior current))) (puthash root prior (symbol-value map)))))) -(defun cj/--projectile-revert-on-fail (_buf status) - "Compilation-finish hook: revert projectile cache on failed-and-modified. -Always self-removes from `compilation-finish-functions' and clears -`cj/--projectile-revert-state'. Reverts the cmd-map entry only when the -compile failed AND the cmd was modified from the captured prior value -AND that prior was non-nil. The unchanged-and-failed case (test fails -because of a real bug) leaves the cache alone." - (remove-hook 'compilation-finish-functions #'cj/--projectile-revert-on-fail) - (let ((state cj/--projectile-revert-state)) - (setq cj/--projectile-revert-state nil) - (cj/--projectile-revert-state-on-fail state status))) - (defun cj/--projectile-make-revert-on-fail-hook (state) "Return a one-shot buffer-local finish hook for projectile revert STATE." (let (hook) @@ -497,15 +480,44 @@ message." ;; ---------- Projectile advice ---------- -(advice-add 'projectile-compile-project :around - (apply-partially #'cj/--projectile-around-revert - 'projectile-compile-cmd-map)) -(advice-add 'projectile-test-project :around - (apply-partially #'cj/--projectile-around-revert - 'projectile-test-cmd-map)) -(advice-add 'projectile-run-project :around - (apply-partially #'cj/--projectile-around-revert - 'projectile-run-cmd-map)) +(defun cj/--projectile-compile-around-revert (orig-fn &rest args) + "Around advice for `projectile-compile-project' command-cache revert." + (apply #'cj/--projectile-around-revert + 'projectile-compile-cmd-map orig-fn args)) + +(defun cj/--projectile-test-around-revert (orig-fn &rest args) + "Around advice for `projectile-test-project' command-cache revert." + (apply #'cj/--projectile-around-revert + 'projectile-test-cmd-map orig-fn args)) + +(defun cj/--projectile-run-around-revert (orig-fn &rest args) + "Around advice for `projectile-run-project' command-cache revert." + (apply #'cj/--projectile-around-revert + 'projectile-run-cmd-map orig-fn args)) + +(defconst cj/--projectile-revert-advice-specs + '((projectile-compile-project . cj/--projectile-compile-around-revert) + (projectile-test-project . cj/--projectile-test-around-revert) + (projectile-run-project . cj/--projectile-run-around-revert)) + "Projectile command runners and their command-cache revert advice.") + +(defun cj/--projectile-install-revert-advice () + "Install Projectile command-cache revert advice when Projectile is available." + (dolist (spec cj/--projectile-revert-advice-specs) + (let ((target (car spec)) + (advice (cdr spec))) + (when (and (fboundp target) + (not (advice-member-p advice target))) + (advice-add target :around advice))))) + +(defun cj/--projectile-register-revert-advice () + "Install Projectile revert advice now, or after Projectile loads." + (if (featurep 'projectile) + (cj/--projectile-install-revert-advice) + (eval-after-load 'projectile + (list 'cj/--projectile-install-revert-advice)))) + +(cj/--projectile-register-revert-advice) ;; ---------- Bindings ---------- diff --git a/tests/test-dev-fkeys--projectile-advice-install.el b/tests/test-dev-fkeys--projectile-advice-install.el new file mode 100644 index 00000000..bfa9b691 --- /dev/null +++ b/tests/test-dev-fkeys--projectile-advice-install.el @@ -0,0 +1,86 @@ +;;; test-dev-fkeys--projectile-advice-install.el --- Tests for Projectile advice setup -*- lexical-binding: t -*- + +;;; Commentary: +;; Smoke tests for the load-order contract between dev-fkeys.el and Projectile. +;; Requiring dev-fkeys should not force Projectile to load, but the command-cache +;; revert advice should be installed once Projectile is available. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'dev-fkeys) + +(ert-deftest test-dev-fkeys-projectile-advice-register-defers-until-projectile-loads () + "When Projectile is not loaded, registration should use `eval-after-load'." + (let (registered-feature registered-form install-called) + (cl-letf (((symbol-function 'featurep) + (lambda (feature) (and (not (eq feature 'projectile)) + (featurep feature)))) + ((symbol-function 'eval-after-load) + (lambda (feature form) + (setq registered-feature feature + registered-form form))) + ((symbol-function 'cj/--projectile-install-revert-advice) + (lambda () (setq install-called t)))) + (cj/--projectile-register-revert-advice)) + (should (eq registered-feature 'projectile)) + (should (equal registered-form '(cj/--projectile-install-revert-advice))) + (should-not install-called))) + +(ert-deftest test-dev-fkeys-projectile-advice-register-installs-when-projectile-loaded () + "When Projectile is already loaded, registration should install immediately." + (let (install-called eval-after-load-called) + (cl-letf (((symbol-function 'featurep) + (lambda (feature) (eq feature 'projectile))) + ((symbol-function 'eval-after-load) + (lambda (&rest _args) (setq eval-after-load-called t))) + ((symbol-function 'cj/--projectile-install-revert-advice) + (lambda () (setq install-called t)))) + (cj/--projectile-register-revert-advice)) + (should install-called) + (should-not eval-after-load-called))) + +(ert-deftest test-dev-fkeys-projectile-advice-install-skips-unbound-projectile-functions () + "The installer should not advise Projectile functions that are not bound." + (let (advised) + (cl-letf (((symbol-function 'fboundp) + (lambda (symbol) + (and (not (memq symbol '(projectile-compile-project + projectile-test-project + projectile-run-project))) + (fboundp symbol)))) + ((symbol-function 'advice-add) + (lambda (symbol &rest _args) + (push symbol advised)))) + (cj/--projectile-install-revert-advice)) + (should-not advised))) + +(ert-deftest test-dev-fkeys-projectile-advice-install-advises-bound-projectile-functions () + "The installer should advise each available Projectile command runner." + (let (advised) + (cl-letf (((symbol-function 'fboundp) + (lambda (symbol) + (or (memq symbol '(projectile-compile-project + projectile-test-project + projectile-run-project)) + (fboundp symbol)))) + ((symbol-function 'advice-member-p) + (lambda (&rest _args) nil)) + ((symbol-function 'advice-add) + (lambda (symbol _where function &rest _args) + (push (list symbol function) advised)))) + (cj/--projectile-install-revert-advice)) + (should (member '(projectile-compile-project + cj/--projectile-compile-around-revert) + advised)) + (should (member '(projectile-test-project + cj/--projectile-test-around-revert) + advised)) + (should (member '(projectile-run-project + cj/--projectile-run-around-revert) + advised)))) + +(provide 'test-dev-fkeys--projectile-advice-install) +;;; test-dev-fkeys--projectile-advice-install.el ends here |
