Skip to content

Commit 9390f9f

Browse files
Prefer tree-sitter for function/class at point commands (#75)
This uses tree-sitter (when available) to obtain the current function/class.
1 parent bfcd288 commit 9390f9f

File tree

3 files changed

+437
-19
lines changed

3 files changed

+437
-19
lines changed

python-pytest.el

+207-19
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
;;; python-pytest.el --- helpers to run pytest -*- lexical-binding: t; -*-
22

33
;; Author: wouter bolsterlee <[email protected]>
4-
;; Version: 3.3.0
4+
;; Version: 3.5.0
55
;; Package-Requires: ((emacs "24.4") (dash "2.18.0") (transient "0.3.7") (s "1.12.0"))
66
;; Keywords: pytest, test, python, languages, processes, tools
77
;; URL: https://github.com/wbolster/emacs-python-pytest
@@ -29,6 +29,7 @@
2929

3030
(require 'projectile nil t)
3131
(require 'project nil t)
32+
(require 'treesit nil t)
3233

3334
(defgroup python-pytest nil
3435
"pytest integration"
@@ -127,6 +128,16 @@ When non-nil only ‘test_foo()’ will match, and nothing else."
127128
(set-default symbol value)
128129
value))))
129130

131+
(defcustom python-pytest-use-treesit (featurep 'treesit)
132+
"Whether to use treesit for getting the node ids of things at point.
133+
134+
Users that are running a version of Emacs that supports treesit
135+
and have the Python language grammar for treesit should set this
136+
variable to t. Users that are running a version of Emacs that
137+
don't support treesit should set this variable to nil."
138+
:group 'python-pytest
139+
:type 'boolean)
140+
130141
(defvar python-pytest--history nil
131142
"History for pytest invocations.")
132143

@@ -178,8 +189,10 @@ When non-nil only ‘test_foo()’ will match, and nothing else."
178189
("F" "file (this)" python-pytest-file)]
179190
[("m" "files" python-pytest-files)
180191
("M" "directories" python-pytest-directories)]
181-
[("d" "def/class (dwim)" python-pytest-function-dwim)
182-
("D" "def/class (this)" python-pytest-function)]])
192+
[("d" "def at point (dwim)" python-pytest-run-def-or-class-at-point-dwim :if-not python-pytest--use-treesit-p)
193+
("D" "def at point" python-pytest-run-def-or-class-at-point :if-not python-pytest--use-treesit-p)
194+
("d" "def at point" python-pytest-run-def-at-point-treesit :if python-pytest--use-treesit-p)
195+
("c" "class at point" python-pytest-run-class-at-point-treesit :if python-pytest--use-treesit-p)]])
183196

184197
(define-obsolete-function-alias 'python-pytest-popup 'python-pytest-dispatch "2.0.0")
185198

@@ -258,35 +271,60 @@ With a prefix argument, allow editing."
258271
:edit current-prefix-arg))
259272

260273
;;;###autoload
261-
(defun python-pytest-function (file func args)
274+
(defun python-pytest-run-def-at-point-treesit ()
275+
"Run def at point."
276+
(interactive)
277+
(python-pytest--run
278+
:args (transient-args 'python-pytest-dispatch)
279+
:file (buffer-file-name)
280+
:node-id (python-pytest--node-id-def-at-point-treesit)
281+
:edit current-prefix-arg))
282+
283+
;;;###autoload
284+
(defun python-pytest-run-class-at-point-treesit ()
285+
"Run class at point."
286+
(interactive)
287+
(python-pytest--run
288+
:args (transient-args 'python-pytest-dispatch)
289+
:file (buffer-file-name)
290+
:node-id (python-pytest--node-id-class-at-point-treesit)
291+
:edit current-prefix-arg))
292+
293+
;;;###autoload
294+
(defun python-pytest-run-def-or-class-at-point (file func args)
262295
"Run pytest on FILE with FUNC (or class).
263296
264297
Additional ARGS are passed along to pytest.
265298
With a prefix argument, allow editing."
266299
(interactive
267300
(list
268301
(buffer-file-name)
269-
(python-pytest--current-defun)
302+
(python-pytest--node-id-def-or-class-at-point)
270303
(transient-args 'python-pytest-dispatch)))
271304
(python-pytest--run
272305
:args args
273306
:file file
274-
:func func
307+
:node-id func
275308
:edit current-prefix-arg))
276309

277310
;;;###autoload
278-
(defun python-pytest-function-dwim (file func args)
279-
"Run pytest on FILE with FUNC (or class).
311+
(defun python-pytest-run-def-or-class-at-point-dwim (file func args)
312+
"Run pytest on FILE using FUNC at point as the node-id.
280313
281-
When run interactively, this tries to work sensibly using
282-
the current file and function around point.
314+
If `python-pytest--test-file-p' returns t for FILE (i.e. the file
315+
is a test file), then this function results in the same behavior
316+
as calling `python-pytest-run-def-at-point'. If
317+
`python-pytest--test-file-p' returns nil for FILE (i.e. the
318+
current file is not a test file), then this function will try to
319+
find related test files and test defs (i.e. sensible match) for
320+
the current file and the def at point.
283321
284322
Additional ARGS are passed along to pytest.
285323
With a prefix argument, allow editing."
286324
(interactive
287325
(list
288326
(buffer-file-name)
289-
(python-pytest--current-defun)
327+
(python-pytest--node-id-def-or-class-at-point)
290328
(transient-args 'python-pytest-dispatch)))
291329
(unless (python-pytest--test-file-p file)
292330
(setq
@@ -313,7 +351,7 @@ With a prefix argument, allow editing."
313351
(python-pytest--run
314352
:args args
315353
:file file
316-
:func func
354+
:node-id func
317355
:edit current-prefix-arg))
318356

319357
;;;###autoload
@@ -360,16 +398,22 @@ With a prefix ARG, allow editing."
360398
map)
361399
"Keymap for `python-pytest-mode' major mode.")
362400

363-
(cl-defun python-pytest--run (&key args file func edit)
364-
"Run pytest for the given arguments."
401+
(cl-defun python-pytest--run (&key args file node-id edit)
402+
"Run pytest for the given arguments.
403+
404+
NODE-ID should be the node id of the test to run. pytest uses
405+
double colon \"::\" for separating components in node ids. For
406+
example, the node-id for a function outside a class is the
407+
function name, the node-id for a function inside a class is
408+
TestClass::test_my_function, the node-id for a function inside a
409+
class that is inside another class is
410+
TestClassParent::TestClassChild::test_my_function."
365411
(setq args (python-pytest--transform-arguments args))
366412
(when (and file (file-name-absolute-p file))
367413
(setq file (python-pytest--relative-file-name file)))
368-
(when func
369-
(setq func (s-replace "." "::" func)))
370414
(let ((command)
371415
(thing (cond
372-
((and file func) (format "%s::%s" file func))
416+
((and file node-id) (format "%s::%s" file node-id))
373417
(file file))))
374418
(when thing
375419
(setq args (-snoc args (python-pytest--shell-quote thing))))
@@ -429,6 +473,17 @@ With a prefix ARG, allow editing."
429473
(setq process (get-buffer-process buffer))
430474
(set-process-sentinel process #'python-pytest--process-sentinel))))
431475

476+
(defun python-pytest--use-treesit-p ()
477+
"Return t if python-pytest-use-treesit is t. Otherwise, return nil.
478+
479+
This function is passed to the parameter :if in
480+
`python-pytest-dispatch'.
481+
482+
Although this function might look useless, the main reason why it
483+
was defined was that the parameter that is provided to the
484+
transient keyword :if must be a function."
485+
python-pytest-use-treesit)
486+
432487
(defun python-pytest--shell-quote (s)
433488
"Quote S for use in a shell command. Like `shell-quote-argument', but prettier."
434489
(if (s-equals-p s (shell-quote-argument s))
@@ -545,7 +600,140 @@ When present ON-REPLACEMENT is substituted, else OFF-REPLACEMENT is appended."
545600

546601
;; python helpers
547602

548-
(defun python-pytest--current-defun ()
603+
(defun python-pytest--point-is-inside-def-treesit ()
604+
(unless (treesit-language-available-p 'python)
605+
(error "This function requires tree-sitter support for python, but it is not available."))
606+
(save-restriction
607+
(widen)
608+
(catch 'return
609+
(let ((current-node (treesit-node-at (point) 'python)))
610+
(while (setq current-node (treesit-node-parent current-node))
611+
(when (equal (treesit-node-type current-node) "function_definition")
612+
(throw 'return t)))))))
613+
614+
(defun python-pytest--point-is-inside-class-treesit ()
615+
(unless (treesit-language-available-p 'python)
616+
(error "This function requires tree-sitter support for python, but it is not available."))
617+
(save-restriction
618+
(widen)
619+
(catch 'return
620+
(let ((current-node (treesit-node-at (point) 'python)))
621+
(while (setq current-node (treesit-node-parent current-node))
622+
(when (equal (treesit-node-type current-node) "class_definition")
623+
(throw 'return t)))))))
624+
625+
(defun python-pytest--node-id-def-at-point-treesit ()
626+
"Return the node id of the def at point.
627+
628+
+ If the test function is not inside a class, its node id is the name
629+
of the function.
630+
+ If the test function is defined inside a class, its node id would
631+
look like: TestGroup::test_my_function.
632+
+ If the test function is defined inside a class that is defined
633+
inside another class, its node id would look like:
634+
TestGroupParent::TestGroupChild::test_my_function."
635+
(unless (python-pytest--point-is-inside-def-treesit)
636+
(error "The point is not inside a def."))
637+
(save-restriction
638+
(widen)
639+
(let ((function
640+
;; Move up to the outermost function
641+
(catch 'return
642+
(let ((current-node (treesit-node-at (point) 'python))
643+
function-node)
644+
(catch 'break
645+
(while (setq current-node (treesit-node-parent current-node))
646+
(when (equal (treesit-node-type current-node) "function_definition")
647+
(setq function-node current-node)
648+
;; At this point, we know that we are on a
649+
;; function. We need to move up to see if the
650+
;; function is inside a function. If that's the
651+
;; case, we move up. This way, we find the
652+
;; outermost function. We need to do this because
653+
;; pytest can't execute functions inside functions,
654+
;; so we must get the function that is not inside
655+
;; other function.
656+
(while (setq current-node (treesit-node-parent current-node))
657+
(when (equal (treesit-node-type current-node) "function_definition")
658+
(setq function-node current-node)))
659+
(throw 'break nil))))
660+
(dolist (child (treesit-node-children function-node))
661+
(when (equal (treesit-node-type child) "identifier")
662+
(throw 'return
663+
(cons
664+
;; Keep a reference to the node that is a
665+
;; function_definition. We need this
666+
;; reference because afterwards we need to
667+
;; move up starting at the current node to
668+
;; find the node id of the class (if there's
669+
;; any) in which the function is defined.
670+
function-node
671+
(buffer-substring-no-properties
672+
(treesit-node-start child)
673+
(treesit-node-end child)))))))))
674+
parents)
675+
;; Move up through the parent nodes to see if the function is
676+
;; defined inside a class and collect the classes to finally build
677+
;; the node id of the current function. Remember that the node id
678+
;; of a function that is defined within nested classes must have
679+
;; the name of the nested classes.
680+
(let ((current-node (car function)))
681+
(while (setq current-node (treesit-node-parent current-node))
682+
(when (equal (treesit-node-type current-node) "class_definition")
683+
(dolist (child (treesit-node-children current-node))
684+
(when (equal (treesit-node-type child) "identifier")
685+
(push (buffer-substring-no-properties
686+
(treesit-node-start child)
687+
(treesit-node-end child))
688+
parents))))))
689+
(string-join `(,@parents ,(cdr function)) "::"))))
690+
691+
(defun python-pytest--node-id-class-at-point-treesit ()
692+
"Return the node id of the class at point.
693+
694+
+ If the class is not inside another class, its node id is the name
695+
of the class.
696+
+ If the class is defined inside another class, the node id of the
697+
class which is contained would be: TestGroupParent::TestGroupChild,
698+
while the node id of the class which contains the other class would
699+
be TestGroupParent."
700+
(unless (python-pytest--point-is-inside-class-treesit)
701+
(error "The point is not inside a class."))
702+
(save-restriction
703+
(widen)
704+
(let ((class
705+
;; Move up to the outermost function
706+
(catch 'return
707+
(let ((current-node (treesit-node-at (point) 'python)))
708+
(catch 'break
709+
(while (setq current-node (treesit-node-parent current-node))
710+
(when (equal (treesit-node-type current-node) "class_definition")
711+
(throw 'break nil))))
712+
(dolist (child (treesit-node-children current-node))
713+
(when (equal (treesit-node-type child) "identifier")
714+
(throw 'return
715+
(cons
716+
;; Keep a reference to the node that is a
717+
;; function_definition
718+
current-node
719+
(buffer-substring-no-properties
720+
(treesit-node-start child)
721+
(treesit-node-end child)))))))))
722+
parents)
723+
;; Move up through the parents to collect the list of classes in
724+
;; which the class is contained. pytest supports running nested
725+
;; classes, but it doesn't support runing nested functions.
726+
(let ((current-node (car class)))
727+
(while (setq current-node (treesit-node-parent current-node))
728+
(when (equal (treesit-node-type current-node) "class_definition")
729+
(dolist (child (treesit-node-children current-node))
730+
(when (equal (treesit-node-type child) "identifier")
731+
(push (buffer-substring-no-properties
732+
(treesit-node-start child)
733+
(treesit-node-end child))
734+
parents))))))
735+
(string-join `(,@parents ,(cdr class)) "::"))))
736+
(defun python-pytest--node-id-def-or-class-at-point ()
549737
"Detect the current function/class (if any)."
550738
(let* ((name
551739
(or (python-info-current-defun)
@@ -565,7 +753,7 @@ When present ON-REPLACEMENT is substituted, else OFF-REPLACEMENT is appended."
565753
(if (s-lowercase? (substring name 0 1))
566754
(car (s-split-up-to "\\." name 1))
567755
name)))
568-
name))
756+
(s-replace "." "::" name)))
569757

570758
(defun python-pytest--make-test-name (func)
571759
"Turn function name FUNC into a name (hopefully) matching its test name.

tests/README.org

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
The following command can be used to run all tests in the directory =tests=. The command should be run in the root directory of the project. The command explicitly loads the file =python-pytest.el= in this repository, this is done to make sure that Emacs uses the symbol definitions from that file instead of other locations that might have the same package (e.g. installed through MELPA.)
2+
3+
#+BEGIN_SRC sh
4+
emacs \
5+
--batch \
6+
--eval '(load-file "./python-pytest.el")' \
7+
--eval '(dolist (file (directory-files-recursively "tests" "\\`[^.].*\\.el\\'\''")) (load-file file))' \
8+
--eval '(ert-run-tests-batch-and-exit)'
9+
#+END_SRC

0 commit comments

Comments
 (0)