Skip to content

Macros

Queries tend to get complex when the target of your search is hard to get at. You may also find yourself searching repeatedly for a particular expression and want to stop repeating yourself or perhaps would like to share that portion with others to reuse in different contexts.

To aid in these regards, SYNG allows you to define your own selectors in the form of a macro: a selector that expands into another.

Macros in SYNG are defined using the (:def) operator, which has the following signature:

clojure
(:def [name] [body])

A macro is named in a way similar to normal selectors, only that it must be invoked with a bang (!) at the end (bang! (you knew I had to do it.)) This is to differentiate macros from built-in selectors. And like normal selectors, macros may accept arguments.

Arguments to a macro are expanded at the position they appear in its body through the use of backref atoms, like \1. For example, the following macro selects both strings and identifiers with the supplied value:

clojure
(:def id-or-str
      (:or (id \1)
           (str \1)))

And invoking it would look like this:

clojure
(call (id-or-str! foo)) ; matches both x("foo") and y(foo)

Reducing complexity

Macros can help reduce the complexity of a query in making it more readable. Consider this awkward and yet ubiquitous block in the Canvas-LMS codebase that is used for translating text into the user's desired locale:

javascript
import {useScope as useI18nScope} from '@canvas/i18n'
const I18n = useI18nScope()

// later:
I18n.t("Hello!")

The programmer looking for calls to I18n.t() has one of three choices:

  1. assume every call-site is using the identifier I18n to bind the result of the call to useI18nScope() and search for (mem t I18n), or
  2. simply look for calls to the function t, or
  3. follow the reference to the output of useI18nScope() and find calls made to t on that reference.

For the sake of this section, assume that they must go with option 3. To arrive at that, they would have to select it as such:

clojure
(mem ; ?.t
  t ; t
  (:ref ; let ? = useScope()
    (call ; useScope()
      (import "@canvas/i18n" useScope)))) ; useScope

It is tiresome to type this everytime they're searching for I18n.t() calls, and it's also distracting from the object of the search. Instead, a macro can be defined to expand into it, under a more meaningful name:

clojure
(:def i18n-t
      (mem t
           (:ref
              (call
                (import "@canvas/i18n" useScope)))))

And with this definition in place, i18n-t! (note the trailing !) can be used to achieve exactly the same effect:

clojure
(call i18n-t! "Hello!") ; matches: I18n.t("Hello!")

Reusing and sharing snippets

WARNING

Macros are an unstable feature. In particular, errors in macro expansion are not traced back to their source (the body of the macro) and result in very confusing error messages. This is a known issue and is being worked on.

Say you're trying to migrate to a different XHR/ajax adapter, the old one called simply ajax and the new one called fetch. Both of them accept HTTP headers in their arguments, but they're buried down in an object at an unpredictable position.

You and others on your team want to ensure the headers were ported correctly and part of that involves searching for the header values:

clojure
(call (:or fetch ajax)
      (arg _
           (obj (prop headers
                      (obj (prop "Content-Type" "application/json"))))))

Let's define an http-header-arg macro to look for such an argument of any key and value:

clojure
(:def http-header-arg
      (arg _
           (obj (prop headers
                      (obj (prop \1 \2))))))

Now, by sharing the macro definition in version control, everyone on the team can issue more semantic queries utilizing the macro:

clojure
(call (:or ajax fetch)
      (http-header-arg! "Content-Type" "application/json"))

SYNG accepts any number of macro definitions declared in a file or inline in the query itself.

Copyright © 2022-present Semantic Works, Inc.