Возможно ли "прозрачный" макролет?

Я хотел бы написать макрос Clojure with-test-tags, который обертывает кучу форм и добавляет некоторые метаданные к имени каждой формы deftest - в частности, добавляет некоторые вещи в ключ :tags так что я могу играть с инструментом для запуска тестов с определенным тегом.

Одной очевидной реализацией для with-test-tags является рекурсивное перемещение всего тела, изменение каждой формы deftest, как я ее нахожу. Но я недавно читал Let Over Lambda, и он делает хороший вывод: вместо того, чтобы просто пропустить код, просто оберните код в macrolet и дайте компилятору пройти его для вас. Что-то вроде:

(defmacro with-test-tags [tags & body]
  `(macrolet [(~'deftest [name# & more#]
                `(~'~'deftest ~(vary-meta name# update-in [:tags] (fnil into []) ~tags)
                   [email protected]#))]
     (do [email protected])))

(with-test-tags [:a :b] 
  (deftest x (...do tests...)))

Это имеет очевидную проблему, однако, что макрос deftest продолжает рекурсивно расширяться навсегда. Я мог бы расширить его до clojure.test/deftest, избегая при этом каких-либо дальнейших рекурсивных расширений, но тогда я не могу с пользой вставить экземпляры with-test-tags для подписи подгрупп тестов.

В этот момент, особенно для чего-то простого, как deftest, похоже, что ходьба кода сама по себе будет проще. Но мне интересно, знает ли кто-нибудь технику написания макроса, который "слегка изменяет" некоторые подвыражения, без повторения навсегда.

Для любопытных: я рассмотрел некоторые другие подходы, такие как наличие времени binding -able var, которое я установил при прохождении вверх и вниз по коду, и используя этот var, когда я наконец увижу deftest, но поскольку каждый макрос возвращает только одно расширение, его привязки не будут доступны для следующего вызова макроэкспонента.

Изменить

Я выполнил реализацию postwalk только сейчас, и, хотя он работает, он не соблюдает специальные формы, такие как quote - он также расширяется внутри них.

(defmacro with-test-tags [tags & body]
  (cons `do
        (postwalk (fn [form]
                    (if (and (seq? form)
                             (symbol? (first form))
                             (= "deftest" (name (first form))))
                      (seq (update-in (vec form) [1]
                                      vary-meta update-in [:tags] (fnil into []) tags))
                      form))
                  body)))

(Кроме того, извините за возможные помехи в теге common- lisp – я подумал, что вы можете помочь с более сложными макросами даже при минимальном опыте Clojure.)

Ответы

Ответ 1

(Это новый подход, eval - и binding -бесплатный. Как обсуждалось в комментарии к этому ответу, использование eval проблематично, потому что он предотвращает закрытие тестов в лексических средах, которые они кажутся (поэтому (let [x 1] (deftest easy (is (= x 1)))) no более длинные работы). Я оставляю исходный подход в нижней половине ответ, ниже горизонтального правила.)

Подход macrolet

Реализация

Протестировано с помощью Clojure 1.3.0-beta2; вероятно, он должен работать с 1.2.x как хорошо.

(ns deftest-magic.core
  (:use [clojure.tools.macro :only [macrolet]]))

(defmacro with-test-tags [tags & body]
  (let [deftest-decl
        (list 'deftest ['name '& 'body]
              (list 'let ['n `(vary-meta ~'name update-in [:tags]
                                         (fnil into #{}) ~tags)
                          'form `(list* '~'clojure.test/deftest ~'n ~'body)]
                    'form))
        with-test-tags-decl
        (list 'with-test-tags ['tags '& 'body]
              `(list* '~'deftest-magic.core/with-test-tags
                      (into ~tags ~'tags) ~'body))]
    `(macrolet [~deftest-decl
                ~with-test-tags-decl]
       [email protected])))

Использование

... лучше всего продемонстрировать с помощью набора (прохождения) тестов:

(ns deftest-magic.test.core
  (:use [deftest-magic.core :only [with-test-tags]])
  (:use clojure.test))

;; defines a test with no tags attached:
(deftest plain-deftest
  (is (= :foo :foo)))

(with-test-tags #{:foo}

  ;; this test will be tagged #{:foo}:
  (deftest foo
    (is true))

  (with-test-tags #{:bar}

    ;; this test will be tagged #{:foo :bar}:
    (deftest foo-bar
      (is true))))

;; confirming the claims made in the comments above:
(deftest test-tags
  (let [plaintest-tags (:tags (meta #'plain-deftest))]
    (is (or (nil? plaintest-tags) (empty? plaintest-tags))))
  (is (= #{:foo} (:tags (meta #'foo))))
  (is (= #{:foo :bar} (:tags (meta #'foo-bar)))))

;; tests can be closures:
(let [x 1]
  (deftest lexical-bindings-no-tags
    (is (= x 1))))

;; this works inside with-test-args as well:
(with-test-tags #{:foo}
  (let [x 1]
    (deftest easy (is true))
    (deftest lexical-bindings-with-tags
      (is (= #{:foo} (:tags (meta #'easy))))
      (is (= x 1)))))

Замечания по дизайну:

  • Мы хотим, чтобы проект macrolet, описанный в вопрос текст произведение. Мы заботимся о возможности гнезда with-test-tags и сохранение возможности определения тестов чьи тела близки к лексическим средам, которые они определены в.

  • Мы будем macrolet ting deftest развернуть до clojure.test/deftest с соответствующими метаданными, прикрепленными к имя теста. Важная часть здесь состоит в том, что with-test-tags вставляет соответствующий набор тегов прямо в определение пользовательский локальный deftest внутри формы macrolet; однажды компилятор приближается к расширению форм deftest, набор тегов будут связаны с кодом.

  • Если мы оставим это, тесты, определенные внутри вложенных with-test-tags будет только помечен тегами, переданными в самой внутренней формы with-test-tags. Таким образом, мы имеем with-test-tags также macrolet символ with-test-tags сам по себе очень похож локальный deftest: он расширяется до вызова верхнего уровня with-test-tags с соответствующими тегами, введенными в множества ярлыков.

  • Предполагается, что внутренняя форма with-test-tags в

    (with-test-tags #{:foo}
      (with-test-tags #{:bar}
        ...))
    

    развернуть до (deftest-magic.core/with-test-tags #{:foo :bar} ...) (если действительно deftest-magic.core - пространство имен with-test-tags определяется в). Эта форма немедленно расширяется в привычную macrolet, с символами deftest и with-test-tags локально привязан к макросам с правильными наборами тегов, их.


(Оригинальный ответ обновлен некоторыми заметками о дизайне, некоторые  перефразировать и переформатировать и т.д. Код не изменяется.)

Подход binding + eval.

(См. также https://gist.github.com/1185513 для версии  дополнительно используя macrolet, чтобы избежать пользовательского верхнего уровня   deftest.)

Реализация

Ниже приведено тестирование на работу с Clojure 1.3.0-beta2; с ^:dynamic удалена часть, она должна работать с 1.2:

(ns deftest-magic.core)

(def ^:dynamic *tags* #{})

(defmacro with-test-tags [tags & body]
  `(binding [*tags* (into *tags* ~tags)]
     [email protected]))

(defmacro deftest [name & body]
  `(let [n# (vary-meta '~name update-in [:tags] (fnil into #{}) *tags*)
         form# (list* 'clojure.test/deftest n# '~body)]
     (eval form#)))

Использование

(ns example.core
  (:use [clojure.test :exclude [deftest]])
  (:use [deftest-magic.core :only [with-test-tags deftest]]))

;; defines a test with an empty set of tags:
(deftest no-tags
  (is true))

(with-test-tags #{:foo}

  ;; this test will be tagged #{:foo}:
  (deftest foo
    (is true))

  (with-test-tags #{:bar}

    ;; this test will be tagged #{:foo :bar}:
    (deftest foo-bar
      (is true))))

Замечания по дизайну

Я думаю, что в этом случае разумное использование eval приводит к полезное решение. Базовая конструкция (основанная на "binding -able Var" идея) состоит из трех компонентов:

  • Динамически связываемый Var - *tags* - который связан при компиляции время для набора меток, которые будут использоваться формами deftest для украшения испытаний. Мы добавляем теги по умолчанию, поэтому его начальный значение #{}.

    Макрос
  • A with-test-tags, который устанавливает подходящий для *tags*.

  • Пользовательский макрос deftest, который расширяется до формы let, напоминающей это (следующее разложение, слегка упрощенное для ясность):

    (let [n    (vary-meta '<NAME> update-in [:tags] (fnil into #{}) *tags*)
          form (list* 'clojure.test/deftest n '<BODY>)]
      (eval form))
    

    <NAME> и <BODY> являются аргументами, deftest, вставлен в соответствующие точки, соответствующие части шаблона расширения с синтаксисом.

Таким образом, расширение пользовательского deftest является формой let, в которой, во-первых, название нового теста готовится путем украшения данного символ с метаданными :tags; то форма a clojure.test/deftest используя это украшенное имя; и, наконец, последняя форма передается eval.

Ключевым моментом здесь является то, что выражения (eval form) здесь оценивается всякий раз, когда пространство имен, содержащееся в них, скомпилировано AOT или требуется в первый раз за всю жизнь JVM, запускающей этот код. Это точно так же, как (println "asdf") в верхний уровень (def asdf (println "asdf")), который напечатает asdf всякий раз, когда пространство имен AOT-компилируется или требуется для первого время; на самом деле, верхний уровень (println "asdf") действует аналогично.

Это объясняется тем, что компиляция в Clojure - это просто оценка всех форм высшего уровня. В (binding [...] (deftest ...), binding - форма верхнего уровня, но она возвращается только тогда, когда deftest и наш пользовательский deftest расширяется до формы, которая возвращается, когда eval делает. (С другой стороны, способ require выполняет верхний уровень код в уже скомпилированных пространствах имен, так что если в вашем коде (def t (System/currentTimeMillis)) значение t будет зависят от того, когда вам требуется пространство имен, а не когда оно было скомпилированный, как можно определить, экспериментируя с AOT-скомпилированным кодом - это просто способ работы Clojure. Используйте read-eval, если хотите, чтобы константы, встроенные в код.)

По сути, пользовательский deftest запускает компилятор (через eval) в время выполнения во время макрообмена. Fun.

Наконец, когда форма a deftest помещается внутри формы with-test-tags form of (eval form) будет подготовлен с привязками установленный на with-test-tags на месте. Таким образом, определение теста будут украшены соответствующим набором тегов.

На REPL

user=> (use 'deftest-magic.core '[clojure.test :exclude [deftest]])
nil
user=> (with-test-tags #{:foo}
         (deftest foo (is true))
         (with-test-tags #{:bar}
           (deftest foo-bar (is true))))
#'user/foo-bar
user=> (meta #'foo)
{:ns #<Namespace user>,
 :name foo,
 :file "NO_SOURCE_PATH",
 :line 2,
 :test #<user$fn__90 [email protected]>,
 :tags #{:foo}}                                         ; <= note the tags
user=> (meta #'foo-bar)
{:ns #<Namespace user>,
 :name foo-bar,
 :file "NO_SOURCE_PATH",
 :line 2,
 :test #<user$fn__94 [email protected]>,
 :tags #{:foo :bar}}                                    ; <= likewise
user=> (deftest quux (is true))
#'user/quux
user=> (meta #'quux)
{:ns #<Namespace user>,
 :name quux,
 :file "NO_SOURCE_PATH",
 :line 5,
 :test #<user$fn__106 [email protected]>,
 :tags #{}}                                             ; <= no tags works too

И только для того, чтобы быть уверенным, что рабочие тесты определены...

user=> (run-tests 'user)

Testing user

Ran 3 tests containing 3 assertions.
0 failures, 0 errors.
{:type :summary, :pass 3, :test 3, :error 0, :fail 0}