summaryrefslogtreecommitdiff
path: root/modules/dev-fkeys.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-03 17:20:54 -0500
committerCraig Jennings <c@cjennings.net>2026-05-03 17:20:54 -0500
commit1d9d252e8b9e1385337cd0af087a7007f8e62da8 (patch)
tree4c774bbed6db0b0167e0235a39d5aeaab021ce9c /modules/dev-fkeys.el
parent2c94acd52cc92dc4ebefd999dbca771367cc3090 (diff)
downloaddotemacs-1d9d252e8b9e1385337cd0af087a7007f8e62da8.tar.gz
dotemacs-1d9d252e8b9e1385337cd0af087a7007f8e62da8.zip
feat(dev-fkeys): add F6 test runner menu (Phase 2a)
I extended `dev-fkeys.el` with the F6 dispatcher half of the spec. F6 prompts via `completing-read` between two candidates: "All tests" delegates to `projectile-test-project`, and "Current file's tests" detects the buffer's language by extension, derives the runner command, and pipes through `compile' from the projectile root. C-F6 is the fast path straight to "Current file's tests". Per-language coverage: - Elisp source files map to `make test-name TEST=^test-<stem>-`. Elisp test files run with `make test-file FILE=<rel-path>` so a per-helper file like `test-foo--bar.el' runs only its own tests. - Python source files map to `pytest tests/test_<stem>.py'. Python test files run with `pytest <rel-path>'. - Go runs the package containing the file: `go test ./<rel-dir>'. Source and test files use the same command since Go test scope is per-package. Limit: this runs every `_test.go' in the package, not just the buffer's file. Phase 2b can refine via test-name discovery. - TypeScript and JavaScript are detected but punted for v1. The runner-command builder returns nil and the orchestrator signals a user-error rather than guessing. The F6 binding moved from the Phase 1 stopgap (`projectile-test-project') to `cj/f6-test-runner'. C-F6 is newly bound to `cj/f6-current-file-tests'. M-F6 stays unbound, reserved for Phase 2b's "Run a test..." menu entry. TDD: 68 new tests across 7 files. Production code split into small testable internals (`cj/--f6-language-detect', `cj/--f6-buffer-is-test-file-p', `cj/--f6-source-stem', `cj/--f6-test-runner-cmd-for', `cj/--f6-current-file-tests-impl') plus two thin interactive wrappers. Smoke tests confirm bindings register on load. I also updated the module commentary with the Phase 2b plan, the capture-then-filter approach for tree-sitter discovery, and a pointer to Emacs bug #79687. The bug is the predicate-syntax mismatch that breaks `:match' / `:equal' / `:pred' queries on Emacs 30.2 with libtree-sitter 0.26. The fix lives on Emacs master (commit b0143530), targets Emacs 31, and has not been backported to the emacs-30 branch as of today. Phase 2b will use queries without predicates and filter results in Elisp, sidestepping the issue. Mike Olson's `treesit-predicate-rewrite.el' applies the same idea to font-lock if you want it before Phase 2b lands.
Diffstat (limited to 'modules/dev-fkeys.el')
-rw-r--r--modules/dev-fkeys.el196
1 files changed, 184 insertions, 12 deletions
diff --git a/modules/dev-fkeys.el b/modules/dev-fkeys.el
index d2c70131..0bb720b1 100644
--- a/modules/dev-fkeys.el
+++ b/modules/dev-fkeys.el
@@ -7,13 +7,33 @@
;; C-F4 fast path: compile only (no-op on interpreted projects)
;; M-F4 fast path: clean + rebuild (no-op on interpreted projects)
;; S-F4 recompile (built-in)
-;; F6 project tests (Phase 1 stopgap; Phase 2 will replace with the
-;; polyglot test runner spec'd in todo.org)
+;; F6 completing-read of test candidates: All tests / Current file's tests
+;; C-F6 fast path: current file's tests
;;
-;; Project-type detection runs against the projectile root and falls back to
-;; 'unknown when no marker matches. Interpreted markers are checked before
-;; compiled markers, so a Python or Node project that also has a Makefile
-;; for tasks classifies as interpreted.
+;; F4 project-type detection runs against the projectile root and falls back
+;; to \\='unknown when no marker matches. Interpreted markers are checked
+;; before compiled markers, so a Python or Node project that also has a
+;; Makefile for tasks classifies as interpreted.
+;;
+;; F6 \"All tests\" delegates to `projectile-test-project'. F6 \"Current
+;; file's tests\" detects the language by extension, derives the runner
+;; command (elisp via the project Makefile, Python via pytest, Go via the
+;; package), and pipes through `compile' from the projectile root.
+;; TypeScript / JavaScript are detected but punted for v1 — the function
+;; signals a user-error rather than guessing a runner.
+;;
+;; M-F6 is reserved for Phase 2b (\"Run a test...\" menu entry with
+;; per-language test-name discovery). Phase 2b also adds buffer-local
+;; last-test memory and tree-sitter-based discovery for Python / Go /
+;; TypeScript. The tree-sitter discovery uses a capture-then-filter pattern
+;; (queries without `:match' / `:equal' / `:pred' predicates, with the
+;; pattern filter applied in Elisp) to sidestep Emacs bug #79687 — Emacs
+;; 30.2 emits unsuffixed `#match' predicates that libtree-sitter 0.26
+;; rejects. The fix lives on Emacs master (commit b0143530) and is
+;; targeted at Emacs 31; it has not been backported to the emacs-30
+;; branch as of 2026-05-03. See Mike Olson's writeup at
+;; https://mwolson.org/blog/emacs/2026-04-20-fixing-typescript-ts-mode-in-emacs-30-2/
+;; for the same workaround applied to font-lock.
;;
;; F7 (coverage) is wired in coverage-core.el. F5 is reserved for the debug
;; ticket and intentionally left unbound here.
@@ -147,6 +167,139 @@ a single Compile entry that calls plain `compile'."
('interpreted '(("Run" . run-only)))
(_ '(("Compile" . compile-plain)))))
+;; ---------- F6 language detection ----------
+
+(defconst cj/--f6-extension-language-map
+ '(("el" . elisp)
+ ("py" . python)
+ ("go" . go)
+ ("ts" . typescript)
+ ("tsx" . typescript)
+ ("js" . javascript)
+ ("jsx" . javascript))
+ "Map of file-extension string to language symbol.
+Used by `cj/--f6-language-detect'.")
+
+(defun cj/--f6-language-detect (filename)
+ "Classify FILENAME's language by extension.
+Returns one of \\='elisp, \\='python, \\='go, \\='typescript, \\='javascript,
+or \\='unknown. Match is case-insensitive. Nil or extensionless input
+returns \\='unknown."
+ (if (or (null filename) (string-empty-p filename))
+ 'unknown
+ (let ((ext (file-name-extension filename)))
+ (or (and ext
+ (cdr (assoc (downcase ext) cj/--f6-extension-language-map)))
+ 'unknown))))
+
+;; ---------- F6 test-file detection ----------
+
+(defun cj/--f6-buffer-is-test-file-p (filename)
+ "Return non-nil if FILENAME's basename matches a test-file naming convention.
+Per language: elisp basenames start with `test-'; Python basenames start
+with `test_' or end with `_test.py'; Go basenames end with `_test.go';
+TypeScript / JavaScript basenames contain `.test.' or `.spec.'. Files
+whose extension we don't classify return nil even if their name happens
+to match a generic pattern."
+ (when (and filename (not (string-empty-p filename)))
+ (let ((language (cj/--f6-language-detect filename))
+ (base (file-name-nondirectory filename)))
+ (pcase language
+ ('elisp (string-prefix-p "test-" base))
+ ('python (or (string-prefix-p "test_" base)
+ (string-suffix-p "_test.py" base)))
+ ('go (string-suffix-p "_test.go" base))
+ ((or 'typescript 'javascript)
+ (or (string-match-p "\\.test\\." base)
+ (string-match-p "\\.spec\\." base)))
+ (_ nil)))))
+
+;; ---------- F6 source-stem extraction ----------
+
+(defun cj/--f6-source-stem (filename)
+ "Return the source-module stem from FILENAME, or nil for nil/empty input.
+Strips directory, extension, and any test-pattern prefix or suffix.
+For elisp test files like `test-foo--bar.el', drops everything from
+`--' onward so the result is the source module name (`foo'). Unsupported
+languages fall back to the basename without extension."
+ (when (and filename (not (string-empty-p filename)))
+ (let ((language (cj/--f6-language-detect filename))
+ (base (file-name-base filename)))
+ (pcase language
+ ('elisp
+ (let ((stripped (if (string-prefix-p "test-" base)
+ (substring base 5)
+ base)))
+ (if (string-match "--" stripped)
+ (substring stripped 0 (match-beginning 0))
+ stripped)))
+ ('python
+ (cond
+ ((string-prefix-p "test_" base) (substring base 5))
+ ((string-suffix-p "_test" base) (substring base 0 -5))
+ (t base)))
+ ('go
+ (if (string-suffix-p "_test" base)
+ (substring base 0 -5)
+ base))
+ (_ base)))))
+
+;; ---------- F6 test-runner command builder ----------
+
+(defun cj/--f6-test-runner-cmd-for (language is-test-file rel-path stem rel-dir)
+ "Return shell command to run tests for the given primitives, or nil.
+LANGUAGE is the language symbol; IS-TEST-FILE is non-nil when the file
+itself is a test file; REL-PATH and REL-DIR are the path and directory
+relative to project root; STEM is the source-module stem.
+
+For elisp test files the command runs only that file via
+`make test-file FILE='. For elisp source files it picks up the matching
+tests by name regex via `make test-name TEST=^test-<stem>-'. Python
+source files map to `pytest tests/test_<stem>.py'; Python test files run
+the file directly. Go runs the package containing the file.
+TypeScript / JavaScript and unknown languages return nil."
+ (pcase language
+ ('elisp
+ (if is-test-file
+ (format "make test-file FILE=%s" rel-path)
+ (format "make test-name TEST=^test-%s-" stem)))
+ ('python
+ (if is-test-file
+ (format "pytest %s" rel-path)
+ (format "pytest tests/test_%s.py" stem)))
+ ('go
+ (format "go test ./%s" rel-dir))
+ (_ nil)))
+
+;; ---------- F6 current-file orchestrator ----------
+
+(defun cj/--f6-current-file-tests-impl (file project-root)
+ "Run the tests for FILE within PROJECT-ROOT.
+Detects language, derives the runner command via
+`cj/--f6-test-runner-cmd-for', and invokes `compile' with
+`default-directory' bound to PROJECT-ROOT. Signals `user-error' for nil
+inputs or files in languages without a runner."
+ (unless file
+ (user-error "F6: no file backing this buffer"))
+ (unless project-root
+ (user-error "F6: no project detected"))
+ (let* ((language (cj/--f6-language-detect file))
+ (rel-path (file-relative-name file project-root))
+ (rel-dir (file-relative-name (file-name-directory file) project-root))
+ (rel-dir (cond
+ ((string= rel-dir "./") "")
+ ((string-suffix-p "/" rel-dir)
+ (substring rel-dir 0 -1))
+ (t rel-dir)))
+ (stem (cj/--f6-source-stem file))
+ (is-test (cj/--f6-buffer-is-test-file-p file))
+ (cmd (cj/--f6-test-runner-cmd-for
+ language is-test rel-path stem rel-dir)))
+ (unless cmd
+ (user-error "F6: no test runner for %s files" language))
+ (let ((default-directory project-root))
+ (compile cmd))))
+
;; ---------- Interactive wrappers ----------
(defun cj/f4-compile-and-run ()
@@ -178,6 +331,28 @@ get a no-op message. Outside any project, falls back to interactive
('interpreted (message "C-F4: not a compiled language"))
(_ (call-interactively #'compile)))))
+(defun cj/f6-test-runner ()
+ "F6 top-level test menu.
+Prompts via `completing-read' between \"All tests\" (delegates to
+`projectile-test-project') and \"Current file's tests\" (delegates to
+`cj/--f6-current-file-tests-impl')."
+ (interactive)
+ (let* ((candidates '("All tests" "Current file's tests"))
+ (label (completing-read "F6: " candidates nil t nil nil (car candidates))))
+ (pcase label
+ ("All tests" (projectile-test-project nil))
+ ("Current file's tests"
+ (cj/--f6-current-file-tests-impl
+ (buffer-file-name) (cj/--f4-project-root))))))
+
+(defun cj/f6-current-file-tests ()
+ "C-F6 fast path: run tests for the current buffer's file.
+Resolves `buffer-file-name' and projectile root, then delegates to
+`cj/--f6-current-file-tests-impl'."
+ (interactive)
+ (cj/--f6-current-file-tests-impl
+ (buffer-file-name) (cj/--f4-project-root)))
+
(defun cj/f4-clean-rebuild ()
"M-F4 fast path: clean + rebuild.
Compiled projects run the heuristic clean + projectile-compile-project
@@ -197,12 +372,9 @@ message."
(keymap-global-set "C-<f4>" #'cj/f4-compile-only)
(keymap-global-set "M-<f4>" #'cj/f4-clean-rebuild)
(keymap-global-set "S-<f4>" #'recompile)
-;; Phase 1 stopgap. Phase 2 replaces this with the polyglot test runner
-;; spec'd in todo.org. Without a global F6 binding here, the per-language
-;; F6→format bindings in prog-c/python/shell would have nothing to fall
-;; back on after this commit drops them, leaving F6 on its useless Emacs
-;; default (`2C-command').
-(keymap-global-set "<f6>" #'projectile-test-project)
+(keymap-global-set "<f6>" #'cj/f6-test-runner)
+(keymap-global-set "C-<f6>" #'cj/f6-current-file-tests)
+;; M-<f6> reserved for Phase 2b ("Run a test..." with last-test memory).
(provide 'dev-fkeys)
;;; dev-fkeys.el ends here.