A curiosity journal of math, physics, programming, astronomy, and more.

Finding test names in source code

Some testing frameworks allow running a specific test by giving a filename and line number, which is convenient for triggering a test from the cursor position in a text editor. Other frameworks, however, require the name of a test, rather than finding it for you based on the line number. This post documents a few of the approaches I've tried to finding the name of a test given its line number.

Line by line regex matching

The simplest solution is to scan from the current cursor line upwards, testing lines against a regex to see if it's a test definition. This mostly works, though it can run into edge cases. For example, with Jest, test names are strings and may concatenated over multiple lines, which then requires more complex logic. Also, JUnit methods are marked as tests with the @Test annotation on the previous line. I don't remember exactly, but cases like these are likely why I switched to a more robust approach, rather than walking line by line in a Vim script.

Zippers

Currently, I try to avoid non-trivial logic in editor scripts and instead invoke a shell script. To find the test name for the current file and line, I parse the file with the tree-sitter CLI and produce an XML representation of a code's concrete syntax tree. That's provides all the information I need, but getting the name in a deeply nested tree structure can be tricky. By habit I used clojure.zip to navigate the tree and find the pieces I needed. The code ended up looking something like this (clojure.zip aliased to z, and a personal library of zipper utilities aliased to zip):

(let [loc (z/xml-zip tree)
      package (-> loc
                  (zip/go z/next (comp #{:package_declaration} :tag))
                  (zip/go z/next (comp #{:scoped_identifier} :tag))
                  z/node
                  :content
                  first)
      [class-name] (-> loc
                       (zip/go z/next (comp #{:class_declaration} :tag))
                       z/node
                       :content
                       (->> (filter (every-pred (comp #{:identifier} :tag)
                                                (comp #{"name"} :field :attrs))))
                       first
                       :content)
      test-name (when (and line (< 1 line))
                  (-> loc
                      (zip/iterate z/next)
                      (->> (filter (comp (partial includes-line line) z/node)))
                      last
                      (zip/go z/up (comp #{:method_declaration} :tag))
                      z/node
                      :content
                      (nth 2)
                      :content
                      first))])

That's a lot of code for a simple task, and it mostly worked, but every now and then would I run into a scenario that didn't quite work and have to fiddle with it. For as fond as I am of zippers, complex traversals begin to feel like imperative programming. Possibly I needed some more helper functions.

Meander

Instead of building out more zipper helpers, I tried Meander. Here are some matchers against an XML tree, which looks for a test class and method, or just a class, or just a function:

(m/search
  (m/$ {:tag :class_definition
        :attrs {:srow (m/pred #(<= (parse-long %) line))}
        :content (_ {:tag :identifier
                     :content ((m/and (m/pred test-class-name?) ?class))}
                    & (m/$ {:tag :function_definition
                            :attrs {:srow (m/app parse-long
                                                 (m/and (m/pred #(<= % line))
                                                        ?row))}
                            :content (m/scan {:tag :identifier
                                              :content ((m/and (m/pred test-function-name?)
                                                               ?fn))})}))})
  , {:row ?row :class ?class :function ?fn}
  (m/$ {:tag :class_definition
        :attrs {:srow (m/app parse-long
                             (m/and (m/pred #(<= % line))
                                    ?row))}
        :content (_ {:tag :identifier
                     :content ((m/and (m/pred test-class-name?)
                                      ?class))}
                    & _)})
  , {:row ?row :class ?class}
  (m/$ {:tag :function_definition
        :attrs {:srow (m/app parse-long (m/and (m/pred #(<= % line))
                                               ?row))}
        :content (_ {:tag :identifier
                     :content ((m/and (m/pred test-function-name?)
                                      ?fn))}
                    & _)})
  , {:row ?row :function ?fn})

:row is used to sort the results and find the match closest to a given line.

Meander's syntax can be tricky to get right, and I'm still wrapping my head around the order of expressions in (m/app f (m/and (m/pred g) ?x)), but in general I'm pleased with how declarative this version is. I don't have to manage traversing a tree, and the code looks a lot like the tree itself, making it easy to handle new scenarios.

At the moment, new cases are handled with new Meander patterns. If you have suggestions for alterative approaches, feel free to email me.