Обзор
Четыре утилиты, которые обычно ставят вместе и обычно описывают по отдельности. По отдельности они выглядят как «быстрый 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 без внешних зависимостей, чем короткая строчка, требующая наличия трёх пакетов.
Источники
- ripgrep — CHANGELOG
- ripgrep — FAQ
- ripgrep — GUIDE
- grep-printer: описание формата
--json - fd — README
- fd — man-страница
- fd — CHANGELOG
- fzf — man-страница
- fzf — CHANGELOG
- fzf — ADVANCED.md, рецепты с ripgrep
- fzf-git.sh
- jq — руководство
- jq — NEWS.md
Связанные заметки
- Fish Shell — справка — оболочка, в которую встраиваются биндинги fzf; там же про
ezaиabbr - Neovim: рабочая справка — редактор на конце связок «найти → открыть»
- tmux cheatsheet — мультиплексор, в котором работает
--popup, и источник конфликта поCtrl-B - Git: модель данных, rebase против merge и спасательные инструменты —
git log,git branchиgit statusкак генераторы кандидатов дляfzf
Из библиотеки института уносят только знания. Читателей здесь не считают: посещение не регистрируется, отметок в издании не остаётся, счётчиков нет ни у одного издания фонда.
Издание пополняется и пересверяется. Текст доступен на условиях CC BY-SA 4.0 — унесённое можно передавать дальше на тех же условиях.