Все части

Оглавление

Пространства имен

При работе с REPL мы всегда находимся в каком-то пространстве. По умолчанию оно написано в приглашении:

user=> (+ 1 2)

Если перейти в другое пространство, изменится и приглашение:

user=> (in-ns 'foobar)
foobar=>

Код, что мы вводим в REPL, вычисляется в текущем пространстве. Если объявить в модуле user переменную:

(in-ns 'user)
(def number 1)

, а затем сослаться на нее в пространстве foobar, получим ошибку, что символ number не найден в текущем контексте:

(in-ns 'foobar)
(+ 1 number)

Syntax error compiling at (repl-chapter:localhost:53495(clj)*:1:8440).
Unable to resolve symbol: number in this context

Другой пример: объявим в модулях user и foobar переменные number со значениями 1 и 2. Теперь одна и та же форма (inc number) даст разный результат в зависимости от того, какое пространство текущее. Поэтому перед вычислением мы должны убедиться, что находимся в нужном пространстве имен.

Чтобы уберечь нас от подобных ошибок, nREPL учитывает параметр ns в сообщениях. Когда мы выполняем код при помощи cider-eval-..., в сообщении, помимо полей op и code, передается ns. Его значение Cider находит из формы (ns...) в начале файла. Вычисляя форму, сервер временно меняет пространство, и результат совпадает с тем, что ожидают.

Все же ручного контроля за текущим пространством не избежать. Переключить его понадобится, например, для того, чтобы вызывать приватную функцию, объявленную с помощью (defn- ...) или (def ^:private ...). Обратиться к ней извне можно только формой resolve или оператором #', что неудобно:

((resolve 'some-ns/private-func) 1 2)
;; or
(#'some-ns/private-func 1 2)

Проще выполнить код в пространстве some-ns — внутри него приватное определение не отличается от обычного.

(in-ns 'some-ns)
(private-func 1 2)

Смена пространства важна при отладке кода, как своего, так и чужого. Мы подробно рассмотрим отладку чуть позже.

Перечислим возможности Cider для контроля за пространствами имен. Команда cider-find-ns вернет список загруженных модулей. В него входят модули проекта, Clojure и сторонние библиотеки. Имена следуют в алфавитном порядке; диалог поддерживает автодополнение.

M-x cider-find-ns

Click on a completion to select it.
In this buffer, type RET to select the completion near point.

Possible completions are:
- aleph.http
- aleph.http.client
- bogus.core
- borkdude.dynaload
- buddy.core.bytes
...

При выборе элемента откроется исходный код модуля. Cider поддерживает в том числе модули из jar-архивов. Например, при выборе clojure.core на ноутбуке автора открывается файл:

/Users/ivan/.m2/repository/org/clojure/clojure/1.10.1/clojure-1.10.1-sources.jar

Буферы из архивов доступны в режиме чтения. Без особых ухищрений нельзя изменить файл в архиве и сохранить его. В особых случаях это необходимо; в секции про отладку мы рассмотрим, как это сделать.

Команда cider-browse-ns покажет переменные модуля. Приведем фрагмент для модуля clojure.core.async:

clojure.core.async
- <! takes a val from port.
- <!! takes a val from port.
- >! puts a val into port.
- >!! puts a val into port.

Каждый элемент открывает буфер с подробностями: документацией, спекой, ссылкой на файл. Работают ссылки на другие определения: из буфера с макросом >!! можно перейти к >!, put! и другим, указанным в секции “Also see”. С помощью cider-browse-ns иногда отпадает нужда в документации.

Cider предлагает многие другие команды для работы с пространствами. Ознакомьтесь с ними на странице документации.

Переход к определению

Программный код — не просто текст; он обладает структурой. Классы, функции и другие элементы строят его скелет. Продвинутый редактор понимает структуру кода и поддерживает переходы по элементам. Например, по нажатию клавиши открыть файл с определением функции под курсором. По нажатию другой клавиши вернуться назад.

Emacs и Cider предлагают разные способы навигации по коду. В этом разделе мы рассмотрим некоторые из них.

Команда M-x cider-find-var запрашивает данные о символе под курсором. Предположим, вы написали код:

(let [name "John"
      email "test@test.com"]
  (format "%s <%s>" name email))

Поместите курсор на слово format и выполните команду. Откроется буфер core.clj из jar-архива на строке 5738, где объявлена функция format.

Информацию о символе находит сервер. Пространство, в котором мы ищем символ, должно быть предварительно загружено. В зависимости от технических деталей клиент посылает команду info или lookup. С сервера приходят данные об имени файла и позиции в нем.

Чтобы вернуться прежний буфер, выполните M-x cider-pop-back. Обратный переход нужен столь же часто, что и прямой. Опытным путем автор пришел к комбинации клавиш C-x .. Добавьте в настройки Emacs выражение:

(global-set-key (kbd "C-x .") 'cider-pop-back)

Переход к определению работает не только с функциями, но и переменными, объектами defmulti, defprotocol и другими. В случае с defmulti вы перейдете к объявлению мультиметода, но не его методов. Убедитесь в этом на примере print-method из clojure.core.

Команда cider-find-var учитывает пространства имен и их псевдонимы. Предположим, в файле следующий заголовок ns:

(ns some-ns
  (:require
   [clojure.walk :as walk]))

Чтобы открыть пространство clojure.walk, поместите курсор на walk и выполните cider-find-var.

В последних версиях Cider произошли изменения: вместо cider-find-var используется более абстрактная команда xref-find-definitions. Она принадлежит встроенному в Emacs пакету Xref для поиска определений и перекрестных ссылок. Особенность Xref в том, что его легко расширить под нужный язык или платформу. Об нем мы расскажем чуть ниже.

Команда cider-javadoc открывает документацию к классу Java. Предположим, мы работаем с сертификатами, и в заголовке ns находятся импорты:

(ns ...
  ...
  (:import
   java.security.cert.CertificateFactory
   java.security.cert.X509Certificate
   java.security.PublicKey))

Наведите курсор на любой класс и выполните M-x cider-javadoc — откроется браузер с документацией к классу и текущей версии JVM. В случае автора страница для X509Certificate оказалась следующей:

https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/security/cert/X509Certificate.html

Команда cider-find-keyword служит для поиска кейвордов. Если навести курсор на ключ :some.ns/name и выполнить ее, Cider попытается:

  • перейти в пространство some.ns
  • сместиться до первого упоминания ::name.

Мы написали “попытается”, потому что способ работает только для ключей, пространство которых совпадает с одноименным модулем. Если у кейворда произвольное пространство, например :book/name, поиск не сработает: пространства book не существует, а перебор всех модулей будет слишком долгим.

Переход к кейворду работает в том числе с псевдонимами (алиасами). Например, если в шапке ns указать пространству псевдоним user и сослаться на кейворд по нему:

(ns some-ns
  (:require
   [company.api.user :as user]))

(get user ::user/email) ;; M-x cider-find-keyword

, то поиск пройдет успешно. До двойному двоеточию nREPL определит, что пространство — псевдоним и раскроет его.

Переход по кейвордам полезен в работе с clojure.spec. Спеки объявляют макросом s/def, который принимает кейворд. Макрос не создает переменную в модуле, а помещает спеку в глобальный реестр с указанными ключом. Найти ее командой cider-find-var будет невозможно. Здесь и выручит вас команда cider-find-keyword, которая работает как навигатор по спекам.

Предположим, вы пишете конфигурацию приложения. Поле :db базы данных использует спеку из модуля clojure.java.jdbc.spec:

(ns some-ns
  (:require
   [clojure.spec.alpha :as s]
   [clojure.java.jdbc.spec :as jdbc]))

(s/def ::db ::jdbc/db-spec)

(s/def ::config
  (s/keys :req-in [::db]))

Чтобы перейти к определению ::jdbc/db-spec, наведите на него курсор и выполните cider-find-keyword. Вы окажетесь в файле spec.clj на строке 78 с макросом s/def:

;; clojure/clojure/java/jdbc/spec.clj
(s/def ::db-spec ...)

Примеры выше подсказывают: используйте ключи, связанные с текущим пространством имен (с двойным двоеточием):

::name
::description
::not-found

В этом случае между ключом и пространством возникает связь, и по одному легко узнать другое. Наоборот, ключ с произвольным пространством словно оторван от кода, и это снижает его возможности:

:user/id
:error/not-found
:api/limit

Представьте, что встретили в коде названную так спеку или событие re-frame. Ни один ключ не дает информации о том, где искать его определение. К сожалению, Cider тоже будет не в силах вам помочь.

Xref

С версии 26 в Emacs появился новый способ навигации по коду. Он называется Xref — от английского cross-reference, перекрестная ссылка. Особенность Xref в дизайне: модуль поддерживает разные источники (бэкенды), откуда приходят данные об определениях. Источником может быть файл тегов, созданный командой ctags для индексирования кода. Также источником может быть функция, если известен иной алгоритм поиска. Например, если это проект на Python, специальный плагин перехватывает вызов Xref и возвращает данные, полученные библиотекой fast-autocomplete или похожей.

Тип бэкенда не влияет на работу пользователя. Поиск и переход по коду сводятся к нескольким командам семейства xref-find-...

Чтобы Cider перехватывал вызовы Xref, установите переменную cider-use-xref в истину. По умолчанию это так, но на всякий случай выполните в *scratch* выражение:

(setq cider-use-xref t)

Откройте любой модуль, загруженный в nREPL. Поместите курсор на символ функции и выполните M-x xref-find-definitions. По аналогии с cider-find-var откроется файл на той строке, где объявлена функция. Способ работает с макросами, протоколами, пространствами имен.

Команде xref-find-definitions назначена комбинация M-. (альт с точкой). Она работает и в других режимах Emacs, например lisp-mode или python-mode (с модулем Anaconda и аналогами).

Чтобы участвовать в поиске, пространство должно быть загружено в REPL. Cider не ищет в локальных файлах, а посылает сообщение серверу. Код на сервере обходит загруженные пространства. Определения получают функцией ns-map, которая возвращает словарь вида символ => определение. Поиск сводится к доступу по словарю, что довольно быстро.

Команда xref-find-references находит места, где встречается указанный символ. С ее помощью легко проверить, нуждается ли проект в определенной функции или нет. Если ссылок на нее не найдено, удалите функцию без опасений. Другое применение команды — рефакторинг, когда вы изменили сигнатуру функции и теперь исправляете ее вызовы.

Другие возможности Xref вы найдете на сайте проекта GNU в разделе Emacs.

Imenu

Плагин Clojure mode расширяет Imenu в Emacs. Imenu (сокращение от Interactive menu) — это встроенный модуль для показа определений в файле. По команде M-x imenu откроется буфер с оглавлением — именами функций, макросов, типов — и приглашением ввода. Приведем краткую версию буфера для модуля clojure.core:

- Function / any?
- Function / str
- Function / symbol?
- Function / keyword?

Для каждого языка Imenu хранит набор правил, по которым ищутся определения. В случае с Clojure это шаблоны (def ...), (defn ...) и другие. Правила можно расширить, чтобы учесть кейворды или особые формы. В этом редко бывает нужда, потому что по умолчанию в Clojure mode заданы обширные правила, в том числе для пакетов clojure.spec и clojure.test (формы s/def, deftest и другие).

Чтобы Imenu работало в файлах Clojure, добавьте в настройки выражение:

(add-hook 'clojure-mode-hook #'imenu-add-menubar-index)

Задайте команде imenu комбинацию клавиш. Автор предпочитает C-i:

(global-set-key (kbd "<C-i>") 'imenu)

Если вы пользуетесь графической версией Emacs, меню удивит вас. Появится системное окно со списком определений. Предполагается, что пользователь переложит руку на мышь и выберет нужный пункт.

Странность решения в том, что всплывающее окно полностью оторвано от Emacs. В нем не работают клавиши перехода по строкам; на больших файлах меню не влазит в экран. Перенос руки с клавиатуры на мышь и обратно нарушает идеи редактора. Поэтому назначьте следующей переменной nil:

(setq imenu-use-popup-menu nil)

Теперь вместо всплывающего окна появится буфер Emacs. В нем работает привычная навигация по элементам.

Интерактивное меню станет еще удобней с пакетом Helm. Установите его командой:

M-x package-install <RET> helm <RET>

Задайте клавишам C-i команду helm-imenu:

(global-set-key (kbd "<C-i>") 'helm-imenu)

Helm предлагает более удобные диалоги. При вводе текста он покажет элементы, которые включают его. Для ввода user получим get-user, delete-user и другие имена. Обычный imenu ищет элементы, которые начинаются с текста, что неудобно, если вы не помните точное имя функции.

Тесты в Cider

Работая над программой, мы постоянно запускаем код в REPL. По-другому подход называют REPL-driven development. У людей, не знакомых с Lisp и Clojure, складывается ошибочное мнение, что тесты не нужны: зачем их писать, если все проверено в REPL?

Это не так: запуск кода в REPL не отменяет тестов. Когда программа закончена, проверки оформляют в тест, чтобы их зафиксировать. Далее код улучшают, постоянно сверяясь с тем, что тест проходит.

Перед тем как начать работу над кодом, убедитесь, что он покрыт тестом. В идеале вы запускаете тест до изменений, чтобы знать предпосылки. Если тест проходит, это гарантирует, что вы отталкиваетесь от штатной ситуации. Если после изменений тест “падает”, ищите причину в ваших действиях.

Случается, что тесты “сломаны” уже до работы над кодом. Например, кто-то измененил код в обход регламента (CI, review). Предварительный прогон тестов покажет, что дело не в ваших изменениях.

В прошлой книге мы подробно разобрали тесты в Clojure. Если коротко, макрос deftest объявляет функцию, чье тело находится в поле метаданных :test. Тесты запускают в особом режиме, когда включены фикстуры и сборщик данных (reporter).

Cider предлагает ряд функций для запуска тестов. Чтобы опробовать их, загрузите модуль с тестами в nREPL. Для этого выполните либо cider-load-buffer (C-c C-k), либо cider-ns-refresh (C-C M-n r). Во втором случае путь к тестам должен быть в classpath. В lein это легко задать полем resource-paths в профиле dev:

{:profiles
 {:dev
  {:resource-paths ["test"]}}}

Приведем минимальный модуль с тестами:

(ns sample-test
  (:require
   [clojure.test :refer [deftest is]]))

(deftest test-orwell
  (is (= 5 (* 2 2))))

Установите курсор в любое место deftest и выполните M-x cider-test-run-test. Команда запустит одиночный тест, при этом на сервер уйдут два сообщения. В первом клиент запросит данные о символе sample/test-orwell. Это необходимо, чтобы убедиться, что sample/test-orwell — действительно тест:

  op   "info"
  sym  "sample/test-orwell"

В о втором сообщении клиент отправит команду test со списком из одного теста:

  op     "test"
  tests  ("test-orwell")

Middleware из пакета cider-nrepl перестроит эту команду в выражение ниже, и тест будет выполнен.

(clojure.test/test-var #'test-orwell)

Также middleware перехватит вывод теста и вернет его в структурированном виде. Вот что получит клиент в положительном случае:

  id      "317"
  column  1
  file    "file:/Users/ivan/work/book-sessions/repl-chapter/src/sample.clj"
  line    6
  name    "test-orwell"
  ns      "sample"
  status  ("done")

и при ошибке:

  id         "320"
  session    "65cc0cad-5a9f-4faa-ba3f-6b2e276b5ba0"
  time-stamp "2022-05-21 20:02:51.069229000"
  gen-input  nil
  results    (dict
               sample (dict
                        test-orwell ((dict "actual" "4\n" "context" nil "diffs"
       (("4\n"
         ("5\n" "4\n")))
       "expected" "5\n" "file" "sample.clj" "index" 0 "line" 7 "message" "" "ns" "sample" "type" "fail" "var" "test-orwell"))))
  summary    (dict
               error 0
               fail  1
               ns    1
               pass  0
               test  1
               var   1)
  testing-ns "sample"

Во втором случае откроется буфер *cider-test-report* с отчетом. Красным цветом показаны места, где оператор (is ...) вернул ложь. Желтым отмечены формы, где возникло исключение. Ниже — отчет о том, что вычисление (* 2 2) не сошлось с ожидаемым результатом (5):

Test Summary
sample

Tested 1 namespaces
Ran 1 assertions, in 1 test functions
1 failures

Results

sample
1 non-passing tests:

Fail in test-orwell

expected: 5
  actual: 4
    diff: - 5
          + 4

Исправьте тест, заменив 5 на 4. Чтобы изменения вступили в силу, выполните deftest при помощи cider-eval-defun-at-point. По аналогии с функциями и переменными, тесты нужно переопределять после изменений. Если запустить тест без этого шага, сработает прошлая версия с ошибкой.

Cider предлагает многие другие удобства для тестов. Команда cider-test-rerun-test повторно выполнит последний запущенный тест. С ней отпадает нужна переключаться между кодом, который вы редактируете, и его тестом. Достаточно выполнить тест и продолжить работу над кодом, время от времени вызывая cider-test-rerun-test.

Команда cider-test-run-ns-tests выполняет тесты определенного пространства. Если вызвать ее в модуле project.sample, Cider запустит тесты в пространстве project.sample-test (при условии, что оно найдено). Следовать этому правилу не обязательно: можно именовать тесты иначе, например с частичкой test в начале:

(ns test.project.sample)

Однако в первом случае их легче выполнить в Cider.

Команда cider-test-rerun-failed-tests выполнит только те тесты из прошлого прогона, что окончились неудачей.

Этих команд достаточно для работы с тестами в Clojure. Полный список вы найдете в документации Cider в разделе Running Tests.

Отладка сообщений nREPL

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

Утилиты tcpdump и Wireshark, что мы рассмотрели выше, в данном случае избыточны. Воспользуйтесь командой nrepl-toggle-message-logging. Она откроет буфер *nrepl-messages* с сообщениями текущей сессии. Приведем пару из них в сокращении:

(-->
  id        "27"
  op        "eval"
  session   "444ea459-4165-4f82-afbc-b9cfbae4d2c5"
  code      "(+ 1 2)"
  column    6
  line      28
  ns        "foo"
)
(<--
  id         "27"
  session    "444ea459-4165-4f82-afbc-b9cfbae4d2c5"
  time-stamp "2022-06-18 17:15:30.451402000"
  value      "3"
)

Направление стрелки означает характер сообщения: от клиента серверу – вправо и обратно – влево. Данные показаны независимо от транспорта (Bencode, EDN), что упрощает их анализ.

Перехват сообщений замедляет работу клиента и поэтому выключен. Повторный ввод команды отключит его.

Отладка кода

Перейдем к наиболее важной части главы: рассмотрим, как отлаживать код на Clojure.

Cider предлагает полноценный отладчик (дебаггер), но по некоторым причинам им пользуются редко. Так происходит потому, что концепции Clojure — неизменяемость, чистые функции, REPL — уже отсекают многие ошибки, свойственные другим языкам. Однако в сложных проектах вам не избежать отладки.

Наивные способы

Прежде чем перейти к отладчику Cider, рассмотрим простые, “народные” способы отладить код — иногда их бывает достаточно. Предположим, функция принимает словарь опций с точками в названии. В ответ она возвращает вложенный словарь кейвордов. Входные данные и результат:

(remap-props {"db.host" "127.0.0.1"
              "db.port" 5432
              "db.settings.ssl" false})

{:db
 {:host "127.0.0.1"
  :port 5432
  :settings {:ssl false}}}

Тело функции:

(require '[clojure.string :as str])

(defn remap-props [props]
  (reduce-kv
   (fn [result k v]
     (let [path
           (mapv keyword (str/split k #"\."))]
       (assoc-in result path v)))
   {}
   props))

Если в словаре окажется поле, отличное от строки, получим ошибку приведения типа:

(remap-props {"db.host" "127.0.0.1" :db/port 5432})

1. Unhandled java.lang.ClassCastException
   class clojure.lang.Keyword cannot be cast to class java.lang.CharSequence
   (clojure.lang.Keyword is in unnamed module of loader 'app';
   java.lang.CharSequence is in module java.base of loader 'bootstrap')

                string.clj:  219  clojure.string/split
                string.clj:  219  clojure.string/split
                      REPL:   28  sample/remap-props/fn
   PersistentArrayMap.java:  377  clojure.lang.PersistentArrayMap/kvreduce
   ...

В отчете нет ни слова о том, какой именно ключ породил исключение. Самый простой способ подглядеть его — вывести на экран на каждом шаге. Для этого добавим println во внутреннюю функцию reduce:

(defn remap-props [props]
  (reduce-kv
   (fn [result k v]
     (println ">>> " k v) ;; debugging
     (let [path
           (mapv keyword (str/split k #"\."))]
       (assoc-in result path v)))
   {}
   props))

Перезагрузите функцию командами cider-eval-last-sexp или cider-eval-defun-at-point, поместив на нее курсор. Вызовите remap-props, и кроме результата в консоли появятся промежуточные шаги reduce:

>>>  db.host 127.0.0.1
>>>  db.port 5432
>>>  db.settings.ssl false

В случае с ошибочным словарем увидим, что дело в ключе :db/port, который не работает с функцией split:

>>>  :db/port 5432

Исправьте функцию так, чтобы она проверяла ключ функцией string?. Если это не так, бросьте исключение с именем ключа. С таким подходом отладка не нужна, потому что ошибка станет явной. Удалите println и перезагрузите функцию.

Печать на экран, при всей примитивности, позволяет быстро найти ошибку в коде. Ниже мы рассмотрим ее вариации.

Функция println выводит данные в одну строку, что неудобно для коллекций. Воспользуйтесь печатью с отступами из пакета clojure.pprint:

(require 'clojure.pprint)

(fn [result k v]
  (clojure.pprint/pprint {:key k :value v})
  ...)

С ней удобно исследовать запросы и ответы HTTP, потому что они описаны большими словарями.

Вернемся к функции get-joke для поиска шуток о программировании. Освежим в памяти ее код:

(defn get-joke [lang]
  (let [request
        {:url "https://v2.jokeapi.dev/joke/Programming"
         :method :get
         :query-params {:contains lang}
         :as :json}

        response
        (client/request request)

        {:keys [body]}
        response

        {:keys [setup delivery]}
        body]

    (format "%s %s" setup delivery)))

Чтобы исследовать ответ сервера, добавьте в let псевдопеременную _ (подчеркивание) и печать response. Это спорный прием, потому что переменная _ не используется: она только уравновешивает форму печати. С другой стороны, не придется разрывать цепочку let-вычислений.

(defn get-joke [lang]
  (let [...
        response
        (client/request request)

        _
        (clojure.pprint/pprint response)

        {:keys [body]}
        response

        ...]))

При поиске шутки вы увидите ответ сервера со статусом, заголовками и телом. Как только печать станет не нужна, мы легко удалим ее.

Вызов pprint влечет несколько неудобств. Во-первых, набирать выражение (clojure.pprint/pprint ...) долго. Во-вторых, нужно импортировать clojure.pprint в REPL, иначе получим ошибку, что модуль не загружен. Пойдем на хитрость: сделаем так, чтобы модуль загружался автоматически. Откройте локальные настройки lein (файл ~/.lein/profiles.clj). В профиль :user добавьте ключ :injections с вектором:

{:user
 :injections [(require 'clojure.pprint)]}

Выражения injections будут выполнены при запуске nREPL. В них размещают код с побочными эффектами, в том числе загрузку модулей. Эта техника служит только для разработки.

Перезагрузите nREPL и выполните (clojure.pprint/pprint ...) в любом месте проекта. Печать сработает без ошибок, и не понадобится импорт clojure.pprint в объявлении пространства (ns ...).

Чтобы быстро вставить pprint в код, обратимся к плагину Emacs wrap-region. С его помощью выделенный текст оборачивают указанными строками. Установите плагин командой:

M-x package-install <RET> wrap-region <RET>

и добавьте в настройки код:

(require 'wrap-region)
(wrap-region-mode t)

(wrap-region-add-wrapper "(clojure.pprint/pprint " ")" "p" 'clojure-mode)

Если теперь выделить response и нажать p, появится выражение (clojure.pprint/pprint response). Вместо response может быть любой текст, в том числе коллекция, макрос, вызов функции.

Иногда pprint выводит слишком много информации, и данные уходят за пределы видимости терминала. Модуль clojure.inspector решает это проблему. Он выводит графическое окно Swing с виджетом дерева. Коллекции обозначены папкой, а их элементы — файлом.

Окно не блокирует поток, который его вызвал. Код отработает без задержек, и вы без спешки изучите данные в инспекторе. Это особенно важно для задач, которые зависят от времени, например отправка HTTP-запросов, чтение и запись в каналы.

По аналогии с clojure.pprint, добавьте в секцию injections форму (require 'clojure.inspector). Задайте клавишу для обертки символа в функцию inspect-tree:

(wrap-region-add-wrapper "(clojure.inspector/inspect-tree " ")" "i" 'clojure-mode)

Внедрение в чужой код

До сих пор мы отлаживали код в директории src. Этот код под вашим контролем: в него легко добавить печать и инспекцию, а затем откатить изменения.

Все меняется, когда нужно отладить стороннюю библиотеку. Код библиотек упакован в jar-файлы и находится в недрах директории ~/.m2. Технически возможно распаковать архив jar, исправить код, упаковать обратно, а затем перезагрузить REPL. Однако это займет массу времени. Способ ниже описывает, как исправить код чужой библиотеки на лету.

Вернемся к функции get-joke для получения шуток. Функция обращается к сервису при помощи библиотеки clj-http. Давайте шагнем в недра clj-http, чтобы отследить, какие данные уходят в сеть.

Наведите курсор на символ client/request и нажмите M-. (или выполните M-x cider-find-var). Откроется модуль clj-http.client из jar-файла в директории ~/.m2/repository/clj-http/clj-http/3.12.0. Вы окажетесь на строке 1134, где объявлена переменная request:

(def ^:dynamic request
  "..."
  (wrap-request #'core/request))

У функции длинная документация, которую мы заменили многоточием. Видно, что request на самом деле ссылается на функцию core/request, обернутую многими middleware. Установите курсор на core/request и снова выполните M-. — вы окажетесь в модуле clj-http.core из того же jar-файла на строке 546:

(defn request
  ([req] (request req nil nil))
  ([{:keys [...]
     :as req} respond raise]
   (let [...]
     ...)))

Буфер clj-http.core открыт в режиме чтения, потому что связан с архивом. Чтобы редактировать код, выполните M-x toggle-read-only. Теперь когда буфер доступен для изменений, добавьте инспекцию перед формой let:

(defn request
  ([req] (request req nil nil))
  ([{:keys [...]
     :as req} respond raise]
   (clojure.inspector/inspect-tree req)
   (let [...]
     ...)))

Обновите функцию на сервере командой M-x cider-eval-defun-at-point. Теперь вызов client/request покажет окно инспектора с полями запроса. Это справедливо не только для функции get-joke, но и для любого обращения к clj-http.

Как только инспекция станет не нужна, вернитесь в буфер clj-http.core. Откатите изменения командой C-/ (undo) и обновите функцию на сервере. Буфер clj-http.core будет отмечен как измененный, и при закрытии Emacs предложит его сохранить. Откажитесь, потому что отладка не должна менять исходный код библиотек. В противном случае Emacs обновит jar-файл, и изменения коснуться каждого проекта с этой библиотекой.

Описанная техника работает со всеми модулями, в том числе встроенными в Clojure. Ради интереса перейдите в модули clojure.walk, clojure.string и другие. Добавьте в код побочные эффекты, проверьте изменения в REPL и откатите их.

Подготовка к отладке

Кроме печати и инспекции Cider предлагает полноценный отладчик. С ним можно выполнить код по шагам, проверить локальные переменные и стек вызовов, словом, делать все, что доступно в современных IDE. Чтобы читатель лучше понял отладку, поговорим об ее устройстве.

Вспомним, как работает отладка в IDE. Напротив строки ставят красную метку (точку останова) и запускают код. Когда исполнение достигает метки, программа останавливается и ждет команды пользователя. При выходе из отладки программа продолжит ход.

Можно сказать, отладка работает как REPL, запущенный в середине кода. Это бесконечный цикл, который читает команду, выполняет ее, выводит результат и ожидает новую команду. Разница в том, что отладчик не только выполняет код. Он хранит локальные переменные, стек вызовов, метрики и так далее.

Напишем простой отладчик для Clojure. Предположим, мы ничего не знаем о Cider и nREPL, поэтому используем только встроенные средства. Подготовим функцию format-user, которую подвергнем отладке:

(ns debug
  (:require [clojure.main :as main]))

(defn format-user
  [{:keys [username email]}]
  (format "%s <%s>" username email))

Проверим ее, подав на вход простой словарь:

(format-user {:username "John"
              :email "john@test.com"})
;; "John <john@test.com>"

Теперь добавим в функцию REPL:

(defn format-user
  [{:keys [username email]}]
  (main/repl :prompt #(print "DEBUG>> "))
  (format "%s <%s>" username email))

Функция repl из модуля clojure.main запустит сеанс REPL с вводом с клавиатуры. Если вставить ее в тело format-user, при запуске она прервется, и откроется приглашение:

user=> (format-user {:username "John"
  #_=>               :email "john@test.com"})
DEBUG>> (+ 1 2)
3

Обратите внимание на разницу в приглашении. Мы задали внутреннему REPL параметр :prompt, чтобы лучше понимать, в каком сеансе пребываем сейчас. Для выхода из отладки нажмите Ctrl/Command+D. Это сочетание подаст на вход символ EOF, что расценивается как сигнал завершения. Управление выйдет из внутреннего REPL, и вы получите результат format-user:

DEBUG>> ;; Ctrl/Command+D
"John <john@test.com>"
user=>

Во время отладки вам захочется узнать локальные переменные, например выяснить, чему равны username и email. Тут вас ждет неприятность: если ввести username, REPL выдаст исключение о том, что символ неизвестен. То же самое относится к переменным let: в примере ниже символы a и b окажутся недоступны.

user=> (let [a 1
  #_=>       b 2]
  #_=>   (main/repl :prompt #(print "DEBUG>> ")))
DEBUG>> a
Syntax error compiling at (REPL:1:1).
Unable to resolve symbol: a in this context
DEBUG>>

Причина в том, что функция eval, с помощью которой REPL выполняет код, не учитывает локальные переменные. Мы упоминали эту проблему в начале главы, и настало время решить ее.

Продвинутый eval

В этом разделе мы напишем функцию eval+ – улучшенную версию обычной eval. Она принимает пространство имен, словарь локальных переменных и форму, которую нужно вычислить. Вот как выглядит ее сигнатура:

(defn eval+ [ns locals form]
  ...)

Представим вызов функции: вычислим форму '(+ a b) при a = 1 и b = 2 в пространстве clojure.core:

(eval+ (the-ns 'clojure.core) {'a 1 'b 2} '(+ a b))
;; 3

Прежде чем писать тело, выясним, где взять входные параметры. С аргументом ns нет сложностей, потому что текущее пространство имен доступно в переменной *ns*:

> *ns*
;; #namespace[user]

Если мы знаем имя пространства, его объект легко получить функцией the-ns:

> (the-ns 'clojure.core)
;; #namespace[clojure.core]

Локальные переменные (второй аргумент locals) — это словарь, ключи которого символы. Он выполняет роль контекста при вычислении формы:

(eval+ ... {'a 1 'b 2} '(+ a b)) ;; 3
(eval+ ... {'a 3 'b 4} '(+ a b)) ;; 7

Как получить локальные переменные для текущего участка кода? Очевидно, их сбор должен быть автоматическим, а не ручным. Поможет макрос get-locals:

(defmacro get-locals []
  (into {} (for [sym (keys &env)]
             [(list 'quote sym) sym])))

Он опирается на скрытую переменную &env, доступную только макросам. Это словарь, где ключи — символы, а значения — экземпляры класса LocalBinding. Класс LocalBinding содержит метаданные о локальных переменных. В метаданных нет значения переменной, но оно не понадобится. Форма (get-locals) возвращает словарь вида:

{'a a, 'b b, ...}

При вычислении он становится тем, что ожидали — словарем локальных переменных:

{a 1, b 2, ...}

Макрос в действии:

(let [a 1
      b 2]
  (get-locals))

;; {a 1, b 2}

Более сложный пример вызовом функции. Видно, что get-locals захватил аргументы a и b и переменную c из формы let:

(defn add [a b]
  (let [c (+ a b)]
    (println (get-locals))
    (+ a b c)))

> (add 1 2)
;; {a 1, b 2, c 3}
6

Теперь когда у нас есть все нужное, подумаем, как выполнить форму. Задача сводится к тому, чтобы связать локальные переменные с eval. Для этого есть несколько способов. Первый — временно сделать локальные переменные глобальными. Назовем этот трюк глобализацией. Чтобы “глобализировать” переменные, нужно:

  • обойти словарь locals в цикле doseq;
  • внедрить переменные в пространство имен функцией intern;
  • вычислить форму при помощи eval и запомнить результат;
  • удалить внедренные переменные при помощи ns-unmap;
  • вернуть результат.

Вот как выглядит черновик eval+ с этим алгоритмом:

(defn eval+ [ns locals form]
  (doseq [[sym value] locals]
    (intern ns sym value))
  (let [result
        (binding [*ns* ns]
          (eval form))]
    (doseq [[sym value] locals]
      (ns-unmap ns sym))
    result))

Проверка показывает, что все верно:

(eval+ *ns* {'a 1 'b 2} '(+ a b))
;; 3

Убедимся, что мы не оставили за собой глобальных переменных: вне формы eval+ символ a неизвестен:

=> a
Syntax error compiling at (repl-chapter:localhost:62378(clj)*:1:8441).
Unable to resolve symbol: a in this context

Обратите внимание, что (eval form) (строка 6) выполняется в рамках binding с привязкой пространства, которое передали в eval+. Без этого вычисление сработает в пространстве clojure.core, и переменные не подхватятся.

Недостаток глобализации в том, что она не учитывает некоторые обстоятельства. Например, если в словаре указана переменная a и такая же переменная задана в пространстве, после очистки мы потеряем ее:

=> (def a 3)

=> (eval+ *ns* {'a 1 'b 2} '(+ a b))
3

=> a
Unable to resolve symbol: a in this context

Доработайте код, чтобы перед вызовом intern и ns-unmap была проверка, существует ли переменная с таким именем. Если существует, переименуйте ее в __old_<var>__. На обратном пути проверьте: если __old_<var>__ существует, верните <var> с прежним значением. Для проверки переменной на существование используйте resolve. Результат будет либо nil, либо Var. Значение переменной легко получить, “дерефнув” Var оператором @ или функций deref, предварительно проверив на nil или с помощью оператора some->.

Второй и более правильный способ выполнить форму с локальными переменными — сдвинуть их внутрь eval. Для этого погрузим форму в оператор let по следующему принципу:

;; locals
{'a 1 'b 2}

;; form
'(+ a b)

;; final form
'(let [a 1 b 2]
   (+ a b))

Если выполнить итоговую форму в REPL, получим ожидаемый результат 3. В этом подходе нет махинаций с глобальными переменными, что делает его безопасней.

Рассмотрим, составить подобную форму let. На первый взгляд задача кажется легкой: это список, где первый элемент — символ let, второй — вектор связывания, а третий — форма, которую вычисляют. Составим функцию make-eval-form:

(defn make-eval-form [locals form]
  (list 'let (vec (mapcat identity locals)) form))

и убедимся в ее работе:

=> (make-eval-form {'a 1 'b 2} '(+ a b))
;; (let [a 1 b 2] (+ a b))

Если выполнить результат в eval, получим 3. Однако более сложные примеры не сработают. Предположим, одна из переменных содержит список — не вектор, а именно список чисел:

(make-eval-form {'numbers (list 1 2 3)}
                '(count numbers))

В результате получится форма:

(let [numbers (1 2 3)]
  (count numbers))

Компилятор не сможет вычислить (1 2 3), потому что 1 не является функцией. Чтобы список остался списком, он должен предстать в виде (list 1 2 3), что требует лишних усилий.

Еще одна ловушка кроется в представлении значений: не все из них могут быть прочитаны парсером Clojure. Например, если напечатать вектор [1 2 3], получим строку, которую можно вставить в код. В широком смысле представление вектора совпадает с его синтаксисом. То же самое относится к словарю и простым типам: числам, строкам, кейвордам. Каждый из них выглядит так же, как в коде.

Однако другие классы представляют объект строкой, которая нарушает синтаксис Clojure. Примером служит класс File:

(new java.io.File "test.txt")
;; #object[java.io.File 0x3c4b88d3 "test.txt"]

Очевидно, строку #object[java.io.File ... "test.txt"] нельзя вычислить в REPL. Выражение с переменной file как в примере ниже:

(make-eval-form
 {'file (new java.io.File "test.txt")}
 '(.getAbsolutePath file))

даст форму, несовместимую с eval:

(let [file #object[java.io.File 0x4e293fac "test.txt"]]
  (.getAbsolutePath file))

Чтобы избежать ошибки, идут на интересный трюк. В правой части вектора let помещают не значение, а код, который получает его из некоего источника. Теперь не нужно опасаться, что объект File вызовет ошибку синтаксиса:

(eval '(let [file (get ... 'file)]
         (slurp file)))

Осталось понять, чем является источник. Подойдет глобальная динамическая переменная *locals*, которую временно связывают с локальными переменными. Это еще одна тонкость функции eval: она игнорирует локальные переменные, но учитывает динамические. Проверим это на примере:

(def ^:dynamic *num* 0)

(binding [*num* 3]
  (eval '(* *num* *num*)))
;; 9

Объявим приватную динамическую переменную *locals*:

(def ^:dynamic ^:private
  *locals* nil)

С ней новая версия eval+ выглядит так:

(defn eval+ [ns locals form]
  (binding [*locals* locals
            *ns* ns]
    (eval `(let ~(reduce
                  (fn [result sym]
                    (conj result sym `(get *locals* '~sym)))
                  []
                  (keys locals))
             ~form))))

Внутренняя форма reduce производит вектор связывания, который становится частью let. Обратите внимание, что значения переменных не участвуют в коде — нужны только их имена, чтобы составить пары вида [x (get *locals* x)]. Поэтому в reduce передаются ключи локальных переменных (форма (keys locals)). Вот что построит reduce для переменных a и b:

[a (get *locals* 'a)
 b (get *locals* 'b)]

Теперь когда функция eval+ готова, перейдем к последнему шагу — напишем свой отладчик для Clojure.

Отладчик своими руками

Наш отладчик представлен макросом break, который работает как точка останова. Он принимает форму и запускает внутренний REPL. Выполнение формы откладывается, и пользователю доступны команды: справка, просмотр переменных, выполнение кода. При выходе из отладки управление переходит к форме. Вот как это выглядит в коде:

(let [a 1
      b 2]
  (break (+ a b)))
;; 3

Перед вычислением (+ a b) запустится REPL, в котором доступны переменные a и b. Когда отладка закончена, получим результат 3.

Отладчик предваряет форму функцией break-inner, которая принимает пространство и локальные переменные. Функция break-inner работает как внутренний REPL с той особенностью, что некоторый ввод считается командой. Пока что реализуем четыре команды: печать локальных переменных, выполнение кода, справку и выход.

(defmacro break [form]
  `(do
     (break-inner *ns* (get-locals))
     ~form))

Договоримся о синтаксисе: ввод !locals означает вывести локальные переменные; по команде !exit отладка завершается. Символ !help служит для справки. Все остальное отладчик воспринимает как код, который нужно выполнить. Вот как выглядит break-inner:

(defn break-inner [ns locals]
  (loop []
    (let [input (read-line)
          form (read-string input)]
      (if (= form '!exit)
        (println "Bye")
        (let [result
              (case form
                !locals locals
                !help "Help message..."
                (eval+ ns locals form))]
          (println result)
          (recur))))))

Добавьте макрос break в любом месте кода и запустите его. Он сработает как точка останова в IDЕ: код прервется, и вы окажетесь в отладке. Приведем сеанс отладчика с простой формой let:

(let [a 1 b 2]
  (break (+ a b)))

=> a
1

=> b
2

=> (+ a a b b)
6

=> !locals
{a 1, b 2}

=> !help
Help message...

=> !exit
3

Команда !exit завершит отладку, и вы получите результат let — число 3.

Примените к отладчику улучшения, что мы рассмотрели в начале главы: печать при помощи pprint, перехват исключений, переменные *1, *2, *3 и *e и все остальное.

Недостаток брейкпоинта в том, что он принимает команды только с клавиатуры. Продвинутая версия должна использовать сетевой протокол или графический интерфейс. В случае с сетью отладчик можно совместить с nREPL. Понадобится middleware, которое расширит протокол новыми полями и их обработкой.

Для интерфейса можно использовать встроенный пакет Swing или веб-сервер с браузером. В момент отладки запускается локальный HTTP-сервер. Функция browse-url из модуля clojure.java.browse открывает браузер по адресу http://127.0.0.1:<port>/debug. Интерфейс строится на технологиях HTML, CSS и JavaScript. Браузер и сервер обмениваются данными через JSON API.

Множественная отладка (теория)

Выше мы покрыли отладкой только одну форму (+ a b). На ней исполнение прервется, а затем продолжится. На практике сложный код исследуют по шагам: от одной формы переходят к другой, пока проблема не устранена. Так происходит потому, что порой трудно понять, где именно закралась ошибка. Точку останова ставят приблизительно и шагают по коду, сверяясь с состоянием программы.

Легко написать макрос debug, который принимает сложную форму и расставляет точки останова в ее содержимом, в том числе вложенным формам. Например, форма let со сложением двух чисел после обработки макросом выглядит так:

(let [a (break 1)   ;; 1
      b (break 2)]  ;; 2
  (break (+ a b)))  ;; 3

Если ее выполнить, процесс станет похож на настоящую отладку. Сперва вы окажетесь в первой точке (break 1). В этот момент не доступна ни одна локальная переменная. В точке (break 2) появится доступ к переменной a. Выйдя из нее, вы окажетесь в третьей точке, где доступны a и b. Покинув третью точку, вы получите результат 3.

Обратите внимание, что в let нельзя оборачивать левую часть связывания. Если сделать как в примере ниже, получим ошибку синтаксиса:

(let [(break a) (break 1)
      (break b) (break 2)]
  (break (+ a b)))

Let, точнее ее внутренний вариант let*, относится к особым формам, синтаксис которых нельзя нарушать. Похоже устроены формы def, defn, if и другие. Некоторые их элементы опорные, потому что на них полагается парсер Clojure.

Мы не будем писать макрос debug, а только предположим, как он выглядит. Макрос принимает форму и обходит ее сверху вниз. Для обхода и изменения дерева понадобятся модули clojure.walk или clojure.zip. Напишем наивную версию макроса:

(require '[clojure.walk :as walk])

(defmacro debug [form]
  (walk/postwalk
   (fn wrap [el]
     (list 'break el))
   form))

Проверим, что получится, если передать в макрос форму let. Для развертки макроса служит функция macroexpand:

(macroexpand
 '(debug
   (let [a 1 b 2]
     (+ a b))))

Результат:

(break
 ((break let)
  (break [(break a) (break 1)
          (break b) (break 2)])
  (break ((break +) (break a) (break b)))))

На выходе форма let, где каждый элемент покрыт точкой останова. Очевидно, мы перестарались, потому что в таком виде результат нельзя скомпилировать. Функция wrap из walk/postwalk должна действовать более тонко. Например, определять формы let, def, if и обрабатывать их особо.

Измените wrap таким образом, чтобы она опиралась на функции needs-debug? и wrap-debug. Первая проверяет, нужно ли оборачивать форму, а вторая делает это с учетом синтаксиса.

(fn wrap [el]
  (if (needs-debug? el)
    (wrap-debug el))
  el)

Это нетривиальное задание: с учетом всех тонкостей оно займет несколько экранов. Сделайте так, чтобы код был расширяемым при помощи мультиметода. Начните с форм let и def, потому что они встречаются чаще других.

Некоторые формы допускают разную запись. Например, у формы defn может быть несколько тел, в def может быть строка документации, pre- и post-проверки и многое другое. Приведите их к единому виду, чтобы не усложнять код проверками if/else. Один из способов это сделать – разобрать форму на части функцией conform из Clojure.spec. Для разбора понадобятся определения; взять их можно из пакета clojure.core.specs.alpha, где собраны спеки основных конструкций: ns, let, def и других.

Отладочный тег

Макросом (break ...) будет проще пользоваться, если назначать ему тег #my/break или похожий. Вот как это выглядит в коде:

(let [a 1 b 2]
  #my/break (+ a b))

Мы добавили пространство my, потому что тег #break уже занят пакетом Cider. Чтобы связать тег с функцией, создайте в директории src файл data_readers.clj с содержимым:

{my/break my.namespace/break-reader}

Ключ этого словаря — имя тега, а значение — полный путь к функции, которая его раскрывает. Функция принимает форму, которая стоит перед тегом и возвращает новую форму. В нашем случае break-reader обернет форму в break:

(defn break-reader [form]
  `(break ~form))

Проведите эксперименты с тегом #my/break. Расставьте их в коде и убедитесь, что отладка запускается. Добавьте в редактор сочетание клавиш, которое ставит тег на текущее место курсора.

Все части