Библиотека — CLI-инструменты поиска: ripgrep, fd, fzf, jq [X]
Шифр
cli-search-toolkit
Разделы
cheatsheet tooling
Сверено
2 сентября — в этот день утверждения статьи сверялись с первоисточниками, а не правился текст
Объём
34 496 знаков · 34 раздела · чтения около 35 минут

CLI-инструменты поиска: ripgrep, fd, fzf, jq


Обзор

Четыре утилиты, которые обычно ставят вместе и обычно описывают по отдельности. По отдельности они выглядят как «быстрый grep», «удобный find», «нечёткий поиск» и «sed для JSON» — то есть как четыре независимых улучшения привычных команд. Ценность на самом деле в другом: у них общая модель ввода-вывода, и поэтому из них собирается конвейер, которого из grep и find не собрать.

Модель простая. rg и fd — быстрые фильтры, которые за доли секунды сводят дерево каталогов к списку строк. fzf берёт список строк и превращает его в интерактивный выбор. Дальше выбранное уходит в действие: открыть в редакторе, переключить ветку, скормить xargs. jq стоит сбоку от этой цепочки — он нужен там, где данные перестали быть строками и стали структурой, и построчные инструменты по ним больше не работают.

Версии, на которые опирается текст (август 2026): ripgrep 15.2.0, fd 10.5.0, fzf 0.74.3, jq 1.8.2. Это важно: у всех четырёх за последние два года менялись умолчания и способ интеграции с оболочкой, и рецепты из статей трёх-четырёхлетней давности местами уже неверны — конкретные расхождения отмечены по ходу текста.

Схема: фильтр → выбор → действие

Три роли стоит различать явно, потому что от этого зависит, кто что делает.

генератор кандидатов  →  интерактивный фильтр  →  действие
   rg / fd / git             fzf                    nvim / git / xargs
   (быстро, много)        (нечётко, мало)         (по одному объекту)

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

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

Обратная ошибка тоньше. Если действие подставляется из результата напрямую, надо помнить про коды возврата: fzf возвращает 0 при выборе, 1 если нет совпадений, 130 при выходе по Esc или Ctrl-C и печатает при этом пустой stdout. Поэтому в скриптах результат сначала кладут в переменную и только потом используют:

file=$(fd --type f | fzf) || return    # Esc → 130, дальше не идём
"${EDITOR:-vi}" "$file"

Без этой проверки нажатие Esc открывает редактор без аргументов, а в связках с git — выполняет команду без операнда.

ripgrep: поиск по содержимому

Что ищется по умолчанию

rg без аргументов пути ищет рекурсивно от текущего каталога — это первое отличие от grep, где рекурсия включается явно. Второе и более важное: rg фильтрует, что именно смотреть. По умолчанию он уважает .gitignore, .ignore, .rgignore, глобальный gitignore и .git/info/exclude, пропускает скрытые файлы и каталоги и не печатает содержимое бинарных файлов, ограничиваясь строкой binary file matches.

Именно из этого, а не из «Rust быстрее C», берётся большая часть выигрыша на репозитории: node_modules, target, .git и сборочные артефакты просто не читаются. Остальное даёт параллельный обход дерева и регулярные выражения на конечных автоматах, которые не уходят в экспоненциальный бэктрекинг.

Отключается всё это одним флагом с накоплением:

Флаг Что добавляет
-u не читать ignore-файлы (--no-ignore)
-uu плюс скрытые файлы (--hidden)
-uuu плюс бинарные файлы как текст (--binary)

Точечные варианты тоже есть: --no-ignore-vcs игнорирует только gitignore, оставляя .ignore в силе, --hidden включает скрытые без снятия остальных правил. Для постоянных исключений, которые не место в .gitignore, заводится .ignore в корне проекта — его читает и rg, и fd.

Отдельная тонкость, которую легко не заметить: явное включающее правило отменяет пропуск скрытых файлов, подпадающих под него. rg foo не увидит .env, а rg foo -g '*.env' — увидит. Отрицающие правила (-g '!*.min.js') так не работают, они только сужают. Проверено на 14.1.0; в 15.x поведение whitelist-правил для скрытых файлов дополнительно чинили (issue #3173), так что при расхождениях стоит смотреть на свою версию.

Типы файлов, контекст, списки

Встроенный словарь типов покрывает около двухсот языков и форматов; rg --type-list показывает его целиком, -t py включает тип, -T md исключает. Свои типы добавляются на лету:

rg --type-add 'web:*.{html,css,js,ts}' -t web 'fetch\('

Контекст задаётся как в GNU grep: -A n после, -B n до, -C n с обеих сторон. Полезнее, чем кажется: при поиске по конфигам одна строка контекста часто и есть ответ.

Два режима вывода стоит помнить отдельно. rg -l (--files-with-matches) печатает только имена файлов — это вход для xargs. rg --files вообще не ищет, а печатает список файлов, которые rg стал бы искать, с учётом всех ignore-правил. Второе делает rg полноценной заменой fd в роли поставщика путей для fzf, если fd не установлен.

--replace: подстановка в выводе, а не в файлах

Флаг -r/--replace регулярно понимают неправильно, и цена ошибки высокая, поэтому формулировка из документации ripgrep стоит того, чтобы её процитировать дословно: «Ни этот флаг, ни какой-либо другой флаг ripgrep не изменяет ваши файлы». --replace меняет только то, что печатается на экран.

rg -r '$2 → $1' '(\w+)@(\w+)' hosts.txt

Ссылки на группы — $1, $2, по имени $name, весь матч $0; для отделения номера от следующего текста нужны фигурные скобки: ${1}a. Обратные слэши в стиле sed (\1) не работают. Строку замены всегда берут в одинарные кавычки, иначе оболочка подставит вместо $1 свои позиционные параметры. Замена применяется к каждому совпадению, а не к строке целиком — чтобы заменить строку целиком, надо и матчить её целиком.

Практическая ценность флага — предпросмотр. Комбинация -r с --passthru (печатать все строки, а не только совпавшие) даёт полный вид файла после будущей правки, ничего не меняя. Реальную же замену делает sed, а rg подбирает ему файлы:

rg -l --null 'foo' | xargs -0 sed -i 's/foo/bar/g'      # GNU sed
rg -l --null 'foo' | xargs -0 sed -i '' 's/foo/bar/g'   # BSD sed (FreeBSD, macOS)

--null и xargs -0 здесь не украшение: без них путь с пробелом развалится на два аргумента. Разница в синтаксисе -i между GNU и BSD — классическая причина того, что рецепт, скопированный из Linux-статьи, на FreeBSD молча создаёт файлы с суффиксом.

С версии 15.0 -r учитывается и в JSON-выводе: в каждом submatch появляется поле replacement, при этом lines.text остаётся исходным. В 14.x замена в --json просто игнорировалась. Man-страница на этот счёт до сих пор отстаёт — в перечне флагов, «не влияющих на --json», --replace всё ещё упомянут.

Многострочный режим

-U/--multiline снимает запрет на то, чтобы совпадение содержало перевод строки: паттерн \n становится допустимым, \p{any} начинает включать \n. А вот . — не начинает. Это ровно то место, где рецепты из интернета чаще всего не работают:

rg -U 'alpha.beta'                    # через перевод строки не найдёт: . не матчит \n
rg -U 'alpha\nbeta'                   # найдёт
rg -U '(?s)fn main.*?\}'              # найдёт: (?s) включает dot-all
rg -U --multiline-dotall 'fn main.*'  # то же самое флагом

Ограничения на число строк в одном совпадении нет. Есть ограничение по ресурсам, и оно существеннее: в многострочном режиме rg обязан видеть файл непрерывным куском памяти, то есть читает его целиком в кучу или отображает через mmap. Данные из stdin при этом вычитываются до EOF перед началом поиска. Если паттерн заведомо не содержит ничего, что могло бы совпасть с \n, rg это замечает и в память файл не тянет — но полагаться на такую оптимизацию вместо того, чтобы просто не включать -U, не стоит.

Движок регулярных выражений

По умолчанию работает движок regex из экосистемы Rust — конечные автоматы с гарантией линейного времени. Плата за гарантию: нет ретроспективных проверок и обратных ссылок. rg 'foo(?=bar)' завершится ошибкой разбора, а не пустым результатом, — это хорошо, ошибку видно сразу.

Когда lookaround действительно нужен, включается PCRE2: rg -P 'foo(?=bar)'. Собранность бинаря с PCRE2 проверяется через rg --pcre2-version; официальные релизные бинарники его содержат. Помнить надо о цене: PCRE2 — бэктрекинг со всеми вытекающими, включая патологические паттерны, и на больших объёмах он заметно медленнее.

Для поиска литералов есть -F/--fixed-strings: быстрее и избавляет от экранирования, когда в строке есть скобки и точки.

fd: поиск по именам

Модель поиска

Ближайший аналог fd pattern в терминах find — это find . -iname '*pattern*', только без цепочки предикатов. Три умолчания, отличающих его от find:

  • паттерн — регулярное выражение, а не glob (glob включается через -g), и сопоставляется с именем файла, а не с полным путём; для полного пути нужен -p/--full-path;
  • поиск по подстроке, не по полному совпадению: fd main найдёт и main.rs, и domain.py; полное совпадение даёт --exact, но флаг новый — он появился только в 10.5.0, до этого приходилось якорить регулярное выражение как fd '^main$';
  • smart-case: запрос в нижнем регистре ищется без учёта регистра, запрос с заглавной буквой — с учётом. Здесь fd и rg расходятся: rg по умолчанию регистрозависим, и smart-case у него включается флагом -S.

Ignore-правила у fd те же, что у rg, и флаги те же: -H скрытые, -I не читать ignore-файлы, -u/--unrestricted — псевдоним для обоих сразу. Это не совпадение — обе утилиты используют одну и ту же библиотеку обхода дерева, поэтому «что видит fd» и «что ищет rg --files» — один и тот же список.

Мелочь, которая ломает старые рецепты: с 8.3.0 fd печатал префикс ./ при выводе в конвейер, а в 8.5.0 это откатили. Сегодня fd | fzf даёт чистые пути, и --strip-cwd-prefix из рецептов тех лет не нужен. Префикс ./ остаётся только при -x, -X и -0 — там он защищает от имён, начинающихся с дефиса.

Фильтры

fd -e md                        # по расширению (без точки)
fd -t f -t l                    # только файлы и симлинки
fd -t d node_modules            # только каталоги
fd -E '*.min.js' -E vendor      # исключения
fd -d 2 conf                    # не глубже двух уровней
fd --changed-within 2d          # изменённые за последние двое суток
fd --changed-before 2026-01-01  # изменённые раньше даты

Типы: f файл, d каталог, l симлинк, x исполняемый, e пустой, s сокет, p FIFO, b и c блочные и символьные устройства. Фильтры x и e устроены иначе прочих: --type executable неявно подразумевает --type file, а --type empty ищет и пустые файлы, и пустые каталоги, пока к нему не добавили -tf или -td.

Про временные фильтры есть свежая ловушка. В 10.3.0 fd перешёл с humantime/chrono на jiff, и вместе с этим M перестало означать «месяц» — теперь для месяцев пишут mo, mos, month, months. Заодно месяцы и годы стали считаться по календарю, а не фиксированным числом секунд. Старый --changed-within 3M теперь означает не то, что раньше.

-x и -X: выполнение команд

Здесь fd заменяет не только find -exec, но и xargs.

-x/--exec запускает команду для каждого результата и делает это параллельно — по числу ядер, если не ограничить -j1. -X/--exec-batch запускает команду один раз, передав все результаты аргументами. Разница принципиальна для интерактивных команд: fd -e md -X nvim откроет один Neovim со всеми файлами, fd -e md -x nvim попытается запустить по редактору на файл.

Плейсхолдеры общие для -x, -X и --format:

Плейсхолдер Значение
{} путь целиком
{/} имя файла
{//} родительский каталог
{.} путь без расширения
{/.} имя файла без расширения

Если плейсхолдера нет вовсе, в конец подставляется неявный {}.

fd -e jpg -x convert {} {.}.png     # рядом с foo.jpg появится foo.png
fd -e log -X du -ch                 # одна сводка по всем логам

Два подводных камня. Первый: -x съедает все последующие аргументы командной строки, поэтому его ставят последним. Второй: при параллельном исполнении порядок недетерминирован, и даже -j1 не даёт порядка, обещанного алфавитом, — он определяется обходом ФС. Если порядок важен, результат надо сортировать явно, а не надеяться на него.

fzf: интерактивный фильтр

Три способа использования

Первый — фильтр посреди конвейера. fzf читает stdin, показывает интерфейс, печатает выбранное в stdout. Всё, что умеет отдавать строки, годится в качестве источника.

Второй — биндинги в оболочке. Ctrl-T подставляет путь в текущую командную строку, Ctrl-R заменяет поиск по истории, Alt-C переходит в выбранный каталог.

Третий — fzf как движок TUI внутри скрипта, когда выбор и действие описываются через --bind, а сам fzf никуда ничего не печатает.

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

eval "$(fzf --bash)"      # bash
source <(fzf --zsh)       # zsh
fzf --fish | source       # fish, в config.fish

Рецепты вида source ~/.fzf/shell/key-bindings.bash или запуск ~/.fzf/install относятся к более ранним версиям. Если из привязок нужна не вся тройка, лишние отключаются пустым значением соответствующей переменной перед eval: FZF_ALT_C_COMMAND= eval "$(fzf --bash)"; для истории — FZF_CTRL_R_COMMAND=.

Язык запросов

Запрос по умолчанию нечёткий: символы должны встретиться в строке по порядку, но не подряд. Поверх этого работает extended-search — несколько термов через пробел, каждый со своим модификатором:

Терм Смысл
foo нечётко
'foo точное вхождение подстроки
'foo' точное вхождение по границам слова (подчёркивание тоже граница)
^foo начало строки
foo$ конец строки
!foo исключить
foo | bar или

Регистр обрабатывается по smart-case, как в fd. Флаг -e/--exact переключает умолчание на точное совпадение, и тогда '-префикс, наоборот, возвращает нечёткость.

Плейсхолдеры и превью

--preview запускает команду для текущей строки и показывает вывод сбоку. {} — вся строка в одинарных кавычках, {1}, {2} — поля по разделителю из --delimiter, {q} — текущий запрос, {n} — порядковый номер, {+} — все выбранные при multi-select. Каждое выражение подставляется уже закавыченным, поэтому свои кавычки вокруг фигурных скобок ставить не нужно и вредно.

fd -t f | fzf --preview 'bat --color=always --style=numbers {}' \
              --preview-window 'right,60%,border-left'

Multi-select включается -m/--multi: Tab переключает пометку у текущей строки и уходит вниз, Shift-Tab — переключает и уходит вверх; на выходе печатаются все помеченные. Для передачи их дальше без проблем с пробелами есть --print0 в пару к xargs -0.

Для действия внутри fzf есть два похожих механизма, и разница между ними существенна. execute(cmd) запускает команду, переключившись на альтернативный экран, и возвращается обратно в fzf. become(cmd) заменяет процесс fzf на команду через execve(2) — fzf исчезает, редактор занимает его место. Для «выбрал файл и открыл» нужен именно become: он не оставляет висеть родительский процесс и корректно отдаёт терминал.

Источник кандидатов

Если FZF_DEFAULT_COMMAND не задана, fzf обходит дерево сам, встроенным walker'ом. Его умолчания — --walker=file,follow,hidden и --walker-skip=.git,node_modules. Ключевое: walker не читает .gitignore, он лишь пропускает каталоги из списка --walker-skip. Отсюда типичная жалоба «Ctrl-T показывает мусор из target/ и build/»; лечится подменой источника:

export FZF_DEFAULT_COMMAND='fd --type f --hidden --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --exclude .git'

FZF_DEFAULT_OPTS задаёт общие опции — высоту, расположение превью, тему. Полезная деталь: fzf сам выбирает схему скоринга по источнику. Когда вход приходит от walker'а или FZF_DEFAULT_COMMAND, включается схема path, дающая бонус компонентам после разделителя пути; для остальных входов — default. То есть подмена источника через переменную сохраняет «путевой» скоринг, а конвейер fd | fzf — нет, и его при желании подправляют явным --scheme=path.

В tmux и в fish

Опция --popup (в 0.71 она получила это имя, --tmux осталась синонимом) запускает fzf в плавающей панели tmux или Zellij поверх текущего окна; нужен tmux 3.3+. Начиная с tmux 3.7 это не модальный popup, а обычная плавающая панель: её можно двигать, растягивать мышью и переключаться из неё в другие панели. Отдельный скрипт fzf-tmux, который раньше делал то же самое, больше не нужен.

Для fish есть тонкость, вытекающая из его собственных умолчаний: fish уже использует Ctrl-R для интерактивного поиска по истории, и интеграция fzf этот биндинг перехватывает (bind \cr fzf-history-widget, плюс то же самое для vi-режима). Заменять или нет — вопрос вкуса; отказаться можно упомянутой выше переменной. Перед подключением полезно посмотреть вывод bind без аргументов: готовые конфигурации дистрибутивов нередко уже заняли часть тех же сочетаний под свои виджеты.

В NixOS вся связка описывается декларативно, включая подстановку источника, — Home Manager отдаёт programs.fzf.enable, programs.fzf.defaultCommand и programs.fzf.enableFishIntegration, которые генерируют то же самое, что fzf --fish | source и set -gx FZF_DEFAULT_COMMAND.

jq: фильтр для структурированного вывода

Модель

jq устроен не как «язык запросов к JSON», а как язык фильтров. Фильтр принимает поток значений и возвращает поток значений; | соединяет фильтры, отдавая выход левого на вход правому — и делает это для каждого значения потока. Отсюда всё остальное: .[] — это фильтр, разворачивающий массив в поток элементов; select(cond) пропускает значение дальше, если условие истинно, и не возвращает ничего, если ложно; map(f) — сахар для [.[] | f].

Понимание этой единственной идеи важнее знания встроенных функций. Как только видно, где поток, а где одно значение, становится очевидно, почему [...] вокруг выражения собирает поток в массив, почему add ждёт на входе массив (или объект), а не поток — для потока в 1.8 добавили отдельный add(f), и почему -s/--slurp (собрать все входные документы в один массив) и -n/--null-input (не читать вход вообще) нужны именно там, где нужны.

jq '.items[] | select(.state == "running") | .name' state.json
jq '[.items[] | .cpu] | add' state.json
jq 'group_by(.state) | map({state: .[0].state, n: length})' state.json

Частые рецепты

Работа с объектом как с набором пар — через to_entries / from_entries / with_entries:

jq 'to_entries | map(select(.value > 2)) | from_entries' limits.json
jq 'with_entries(select(.key | startswith("x-")))' headers.json

Рекурсивный спуск .. находит значения на неизвестной глубине; paths и getpath дают пути к ним. Обработка отсутствующих полей — оператор альтернативы // (взять правое, если левое null или false) и постфиксный ? (подавить ошибку типа):

jq -r '.[] | .zone // "unknown"' hosts.json
jq -r '.. | .name? // empty' tree.json

Внешние значения приходят через --arg name value (как строка) и --argjson name json (как разобранный JSON); окружение доступно как env и $ENV. Подставлять значения интерполяцией шелла в текст фильтра не надо — это ровно тот же класс ошибок, что и SQL-инъекция.

jq -r --arg tag prod '.[] | select(.tags | index($tag)) | .name' hosts.json

Для действительно больших документов есть --stream: вместо целого дерева jq выдаёт поток пар «путь — значение», и память не растёт по размеру входа. Синтаксис заметно менее приятный, поэтому имеет смысл только тогда, когда документ не помещается в память.

Вывод для оболочки

Это то место, где jq возвращается в мир строк, и здесь чаще всего ошибаются.

-r/--raw-output печатает строки без кавычек и без экранирования — обязателен, если результат пойдёт в другую команду. @tsv и @csv берут массив и выдают корректно экранированную строку, что надёжнее ручной склейки через \(...):

jq -r '.items[] | [.name, .state, (.cpu|tostring)] | @tsv' state.json

Когда результат уходит в xargs, единственный полностью безопасный вариант — --raw-output0, появившийся в 1.7: разделителем становится нулевой байт, и имена с пробелами и переводами строк перестают быть проблемой.

jq -r --raw-output0 '.items[].path' state.json | xargs -0 -n1 wc -c

Из версионных различий, о которые можно споткнуться на чужих скриптах, стоит знать про 1.8.0: indices, index и rindex перешли на индексы по кодовым точкам вместо байтов (для байтового смещения теперь нужен utf8bytelength), tonumber перестал принимать строки с пробелами по краям, ltrimstr/rtrimstr начали падать на нестроковом входе, а --indent 0 перестал неявно включать --compact-output. Тогда же добавились trim/ltrim/rtrim, trimstr, abs, toboolean, skip/2 и pick.

Связки

fd → fzf → Neovim

Базовый случай, из которого выводятся остальные:

f() {
  local file
  file=$(fd --type f --hidden --exclude .git |
           fzf --preview 'bat --color=always --style=numbers {}') || return
  nvim "$file"
}

Тот же смысл на fish, где and заменяет проверку кода возврата:

function f --description 'найти файл и открыть в редакторе'
    set -l file (fd --type f --hidden --exclude .git |
                 fzf --preview 'bat --color=always --style=numbers {}')
    test -n "$file"; and nvim $file
end

Вариант без обёртки — переложить открытие на сам fzf через become, тогда функция не нужна вовсе:

fd --type f | fzf --bind 'enter:become(nvim {})' \
                  --preview 'bat --color=always {}'

Для нескольких файлов сразу: --multi плюс {+}, который развернётся в список закавыченных путей, — fzf -m --bind 'enter:become(nvim {+})'.

rg → fzf --preview → нужная строка

Здесь уже недостаточно передать путь: нужно попасть в конкретную строку. Ключ — заставить rg печатать машиночитаемый префикс, а fzf разобрать его на поля.

rg --line-number --no-heading --color=always --smart-case "$1" |
  fzf --ansi \
      --delimiter : \
      --preview 'bat --color=always {1} --highlight-line {2}' \
      --preview-window 'up,60%,border-bottom,+{2}+3/3,~3' \
      --bind 'enter:become(nvim {1} +{2})'

Разбор по частям. rg печатает путь:строка:текст; --delimiter : делает первое поле {1}, второе {2}. --color=always вынуждает добавить --ansi, иначе fzf покажет escape-последовательности как текст. В --preview-window компонент +{2}+3/3 задаёт смещение прокрутки превью так, чтобы найденная строка оказалась ближе к центру, а ~3 фиксирует три верхние строки как шапку. nvim {1} +{2} — синтаксис самого Neovim: открыть файл и перейти на строку.

У этой связки есть предел: rg отрабатывает один раз при запуске, дальше fzf фильтрует уже готовый список. Если хочется, чтобы запрос уходил обратно в rg (то есть искать по содержимому, а не по строкам вывода), фильтрацию у fzf отключают и перезапускают rg на каждое изменение запроса:

RG='rg --column --line-number --no-heading --color=always --smart-case '
fzf --ansi --disabled --query "$1" \
    --bind "start:reload:$RG {q} || true" \
    --bind "change:reload:sleep 0.1; $RG {q} || true" \
    --delimiter : \
    --preview 'bat --color=always {1} --highlight-line {2}' \
    --bind 'enter:become(nvim {1} +{2})'

--disabled снимает с fzf роль фильтра, {q} — текущий запрос, || true гасит ненулевой код rg, когда совпадений нет (иначе reload посчитает это ошибкой), sleep 0.1 работает как debounce и убирает шквал процессов rg при быстром наборе.

rg --json → jq

rg --json печатает JSON Lines: по объекту на строку, пять типов сообщений — begin, match, context, end, summary. Это единственный способ разобрать результаты поиска надёжно: в обычном выводе двоеточие является и разделителем, и допустимым символом в пути.

rg --json 'TODO' |
  jq -r 'select(.type == "match")
         | [.data.path.text,
            (.data.line_number | tostring),
            (.data.lines.text | rtrimstr("\n"))]
         | @tsv'

Сводка по файлам берётся из сообщений end, где лежит статистика по каждому файлу:

rg --json 'TODO' |
  jq -r 'select(.type == "end")
         | select(.data.stats.matches > 0)
         | "\(.data.stats.matches)\t\(.data.path.text)"' |
  sort -rn

Одна ловушка формата: пути и содержимое не обязаны быть валидным UTF-8, поэтому каждый такой элемент — объект либо с ключом text, либо с ключом bytes (base64). Скрипт, безусловно читающий .data.path.text, однажды получит null на файле с именем в другой кодировке. Устойчивый вариант — (.data.path.text // (.data.path.bytes | @base64d)).

При включённом --json статистика собирается всегда, а флаги вроде --files-with-matches, -c и --count-matches с ним несовместимы и вызывают ошибку.

git через fzf

Git — идеальный источник кандидатов: он выдаёт стабильно форматированные строки, а --preview превращает список хешей в нечто читаемое.

Ветка:

b=$(git for-each-ref --format='%(refname:short)' refs/heads |
      fzf --preview 'git log --oneline --graph --color=always -n 30 {}') &&
  git switch "$b"

Коммит — первое поле git log --oneline и есть хеш, дальше он уходит и в превью, и в действие:

git log --oneline --color=always |
  fzf --ansi --no-sort \
      --preview 'git show --color=always --stat {1}' \
      --bind 'enter:become(git show --color=always {1} | less -R)'

--no-sort здесь важен: без него fzf переупорядочит коммиты по релевантности и хронология истории потеряется.

Откат правок в выбранных файлах:

git diff --name-only |
  fzf --multi --print0 --preview 'git diff --color=always -- {}' |
  xargs -0 git restore --

--print0 в паре с xargs -0 спасает от путей с пробелами. Команда деструктивна и подтверждения не спрашивает — git restore затирает изменения в рабочем дереве без возможности вернуть их.

Готовый набор таких привязок есть у автора fzf — fzf-git.sh, отдельный проект для bash, zsh и fish. Он вешает всё на префикс Ctrl-G: Ctrl-G Ctrl-F файлы, Ctrl-G Ctrl-B ветки, Ctrl-G Ctrl-H хеши коммитов, Ctrl-G Ctrl-S stash'и, Ctrl-G Ctrl-W worktree'и, Ctrl-G ? список. Две оговорки из его же документации стоит держать в голове: Ctrl-G Ctrl-B не сработает, если Ctrl-B занят префиксом tmux, а Ctrl-G Ctrl-S — при включённом программном управлении потоком, где Ctrl-S замораживает терминал (снимается через stty -ixon). В обоих случаях выручает форма без второго Ctrl: Ctrl-G b, Ctrl-G s.

Переход с привычных команд

Таблица соответствий

Задача Классика Замена Что меняется
Поиск текста в дереве grep -rn pat . rg pat рекурсия и ignore-правила по умолчанию
Поиск текста в одном файле grep pat f rg pat f почти совпадает
Список файлов с совпадением grep -rl pat . rg -l pat совпадает
Поиск файла по имени find . -name '*.md' fd -e md regex по подстроке имени вместо glob целиком
Поиск каталога find . -type d -name x fd -t d x совпадает по смыслу
Команда над найденным find . -name '*.j' -exec c {} \; fd -e j -x c {} параллельно, без \;
Одна команда над всеми find … -exec c {} + fd … -X c совпадает по смыслу
Листинг каталога ls -l eza -l иконки, git-статусы (см. справку по fish)
Разбор JSON-вывода grep/awk jq структура вместо строк

Строчка про eza здесь для полноты картины: сама утилита и набор алиасов вокруг неё разобраны в справке по fish.

Установка

Система Команда
Debian/Ubuntu apt install ripgrep fd-find fzf jq
Fedora dnf install ripgrep fd-find fzf jq
Arch pacman -S ripgrep fd fzf jq
NixOS environment.systemPackages = with pkgs; [ ripgrep fd fzf jq ];
FreeBSD pkg install ripgrep fd-find fzf jq

Имя пакета fd-find встречается в трёх экосистемах и означает в них разное. На Debian и Ubuntu пакет ставит бинарник под именем fdfind — имя fd занято пакетом fdclone; чтобы рецепты из документации работали как написано, обычно добавляют симлинк ln -s "$(which fdfind)" ~/.local/bin/fd. На Fedora и во FreeBSD пакет называется так же, но исполняемый файл — обычный fd. Отдельная путаница: .deb с релизной страницы проекта ставит именно fd (плюс, начиная с 10.2.0, симлинк fdfind для совместимости), то есть отличается от дистрибутивного пакета с тем же назначением.

В RHEL и клонах ripgrep и fzf в базовых репозиториях отсутствуют и приходят из EPEL — оттуда же стоит искать и fd. jq есть практически везде, в базовых репозиториях в том числе.

Отставание версий в стабильных дистрибутивах здесь ощутимее, чем обычно, потому что расходятся не мелочи, а умолчания: вшитая интеграция fzf с оболочкой (0.48), --tmux/--popup (0.53 и 0.71), учёт -r в rg --json (15.0), смысл M в fd --changed-within (10.3.0). Если рецепт из этой статьи не работает, первое, что стоит проверить, — rg --version, fd --version, fzf --version, jq --version.

Об алиасах

Соблазн написать alias grep=rg и alias find=fd возникает у всех, и это плохая идея по трём независимым причинам.

Первая — флаги не совпадают, причём худшим из возможных способов: не «нет такого флага», а «есть, но другой». Самый показательный случай — -E: у grep это extended regular expressions, у rg — --encoding, то есть rg -E '(foo|bar)' не включит расширенный синтаксис, а попробует прочитать файлы в кодировке с таким именем и завершится с unknown encoding. Здесь ещё повезло — ошибка видна сразу; хуже, когда чужой флаг у замены существует и молча делает что-то другое. find -mtime и find -exec … \; у fd просто отсутствуют, и любая команда из чужого рецепта, из README или из ответа на форуме ломается на первой же строке.

Вторая — умолчания не совпадают. rg не покажет файлы из .gitignore и скрытые. Для интерактивной работы это преимущество, для проверки «а есть ли вообще где-нибудь такая строка» — тихая потеря результатов. Скрипт, где grep подменён на rg, может годами работать правильно и однажды не найти то, что нашёл бы grep.

Третья — алиасы не видны скриптам. Неинтерактивная оболочка их не разворачивает, так что #!/bin/bash со словом grep внутри всегда получит настоящий grep. Значит, привычка, выработанная на алиасах, на скрипты не переносится вовсе, и в момент, когда рабочая команда переезжает в файл, она перестаёт работать.

Разумная граница: новым командам — новые имена. Короткие обёртки под свои задачи (f для «найти и открыть», rgh для rg --hidden --no-ignore) не конфликтуют ни с чем и переносятся между машинами вместе с конфигом оболочки. В fish для таких вещей лучше подходит не alias, а abbr: аббревиатура раскрывается в полную команду прямо в командной строке, так что перед выполнением видно, что именно запускается, и в историю попадает развёрнутый вид — а значит, эту же строку можно скопировать на машину, где никакого конфига нет.

Где классические инструменты остаются

Замена не бывает полной, и полезно заранее знать границу.

grep остаётся в скриптах, которые должны работать на чужой машине без установки чего-либо, — он есть в любой POSIX-системе, включая busybox-окружения и минимальные контейнеры. Там же, где важно строгое соответствие POSIX-семантике basic/extended regular expressions, rg не подходит по определению: у него другой диалект.

find остаётся везде, где нужны предикаты, которых у fd нет: -perm, -user, -inum, -links, -newer в сравнении с конкретным файлом, комбинации через -o/-a/! со скобками. fd покрывает частый случай «найти по имени и типу», а не полную алгебру предикатов find.

sed и awk не заменяются ничем из перечисленного. rg -r не пишет в файлы принципиально, jq работает только с JSON. Для потокового редактирования текста альтернативы остаются теми же.

И общее правило: интерактивная работа и скрипт — разные жанры. В интерактивной сессии rg, fd и fzf выигрывают всегда, потому что экономят внимание. В скрипте, который будет читать кто-то другой через год, лучше стоит явный find -type f -name '*.log' -mtime +7 без внешних зависимостей, чем короткая строчка, требующая наличия трёх пакетов.

Источники


Связанные заметки

Из библиотеки института уносят только знания. Читателей здесь не считают: посещение не регистрируется, отметок в издании не остаётся, счётчиков нет ни у одного издания фонда.

Издание пополняется и пересверяется. Текст доступен на условиях CC BY-SA 4.0 — унесённое можно передавать дальше на тех же условиях.