REPL, Cider, Emacs (часть 3/4)

Все части

Оглавление

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

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

user=> (+ 1 2)

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

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

Каждая форма вычисляется в каком-то пространстве. Если объявить в модуле 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 "[email protected]"]
  (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 в том, что его легко расширить под нужный язык или платформу. Об 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 попытается:

Мы написали “попытается”, потому что способ работает только для ключей, для пространства которых найден модуль Clojure. Если у кейворда произвольное пространство, например :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

Представьте, что встретили в коде один из этих ключей. Ни один из них не дает информации о том, где искать определение. К сожалению, 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

Cider зависит от плагина 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 и другие).

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

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

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

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

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

Странность решения в том, что всплывающее окно полностью оторвано от Emacs. В нем не работают клавиши перехода по строкам; на больших файлах меню не влазит в экран. Перенос руки с клавиатуры на мышь и обратно противоположен идеям редактора. Поэтому для эффективной работы с Imenu назначьте следующей переменной 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 ...) вернул ложь. Желтым отмечены формы, где возникло исключение. Ниже — отчет о том, что вычисления в test-orwell (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 на конце). Следовать этому правилу не обязательно: можно именовать модули с тестами иначе, например с частичкой 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. Предположим, мы ничего не знаем об nREPL и Cider, поэтому используем только встроенные модули. Подготовим функцию 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 "[email protected]"})
;; "John <[email protected]>"

Теперь добавим в функцию 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 "[email protected]"})
DEBUG>> (+ 1 2)
3

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

DEBUG>> ;; Ctrl/Command+D
"John <[email protected]>"
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, второй аргумент) — это словарь, ключи которого символы. Он выполняет роль контекста при вычислении формы:

'(+ a b) + {'a 1 'b 2} = 3
'(+ a b) + {'a 3 'b 4} = 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+ с этим алгоритмом:

(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.

Второй и более правильный способ выполнить форму с локальными переменными — сдвинуть их внутрь 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 передаются ключи локальных переменных. Вот что построит 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 следует код, который захватил макрос break.

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

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

Договоримся о синтаксисе: ввод !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>. Интерфейс строится на технологиях HTML, CSS и JavaScript. Браузер и сервер обмениваются данными через JSON API на базе REST или RPC.

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

Выше мы покрыли отладкой только одну форму (+ 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, потому что она встречается чаще других, и в ней протекает больше вычислений.

Возможно, вам понадобится парсинг сложных форм при помощи clojure.spec. Например, у формы defn может быть несколько тел. Чтобы не проверять это в коде, мультиметод принимает структуру данных, полученную в результате s/conform и спеки defn. Модуль clojure.core.specs.alpha содержит спеки основных конструкций Clojure: 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. Расставьте их в коде и убедитесь, что отладка запускается. Добавьте в редактор сочетание клавиш, которое ставит тег на текущее место курсора.

Все части