---
tags: [tooling, cheatsheet]
verified: 2026-09-02
---

# 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. Это важно: у всех четырёх за последние два года менялись умолчания и способ интеграции с оболочкой, и рецепты из статей трёх-четырёхлетней давности местами уже неверны — конкретные расхождения отмечены по ходу текста.

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

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

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

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

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

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

```bash
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` исключает. Свои типы добавляются на лету:

```bash
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` меняет только то, что печатается на экран.

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

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

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

```bash
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`. А вот `.` — не начинает. Это ровно то место, где рецепты из интернета чаще всего не работают:

```bash
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` — там он защищает от имён, начинающихся с дефиса.

### Фильтры

```bash
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`:

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

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

```bash
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 скрипты интеграции вшиты в бинарник, и подключаются они так:

```bash
eval "$(fzf --bash)"      # bash
source <(fzf --zsh)       # zsh
```

```fish
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. Каждое выражение подставляется уже закавыченным, поэтому свои кавычки вокруг фигурных скобок ставить не нужно и вредно.

```bash
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/`»; лечится подменой источника:

```bash
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` (не читать вход вообще) нужны именно там, где нужны.

```bash
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`:

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

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

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

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

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

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

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

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

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

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

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

```bash
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

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

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

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

```fish
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`, тогда функция не нужна вовсе:

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

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

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

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

```bash
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` на каждое изменение запроса:

```bash
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`. Это единственный способ разобрать результаты поиска надёжно: в обычном выводе двоеточие является и разделителем, и допустимым символом в пути.

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

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

```bash
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` превращает список хешей в нечто читаемое.

Ветка:

```bash
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` и есть хеш, дальше он уходит и в превью, и в действие:

```bash
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 переупорядочит коммиты по релевантности и хронология истории потеряется.

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

```bash
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](./fish-reference.md).

### Установка

| Система | Команда |
|---------|---------|
| 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](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [ripgrep — FAQ](https://github.com/BurntSushi/ripgrep/blob/master/FAQ.md)
- [ripgrep — GUIDE](https://github.com/BurntSushi/ripgrep/blob/master/GUIDE.md)
- [grep-printer: описание формата `--json`](https://docs.rs/grep-printer/latest/grep_printer/struct.JSON.html)
- [fd — README](https://github.com/sharkdp/fd)
- [fd — man-страница](https://github.com/sharkdp/fd/blob/master/doc/fd.1)
- [fd — CHANGELOG](https://github.com/sharkdp/fd/blob/master/CHANGELOG.md)
- [fzf — man-страница](https://github.com/junegunn/fzf/blob/master/man/man1/fzf.1)
- [fzf — CHANGELOG](https://github.com/junegunn/fzf/blob/master/CHANGELOG.md)
- [fzf — ADVANCED.md, рецепты с ripgrep](https://github.com/junegunn/fzf/blob/master/ADVANCED.md)
- [fzf-git.sh](https://github.com/junegunn/fzf-git.sh)
- [jq — руководство](https://jqlang.org/manual/)
- [jq — NEWS.md](https://github.com/jqlang/jq/blob/master/NEWS.md)

---

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

- [Fish Shell — справка](./fish-reference.md) — оболочка, в которую встраиваются биндинги fzf; там же про `eza` и `abbr`
- [Neovim: рабочая справка](./nvim-cheatsheet.md) — редактор на конце связок «найти → открыть»
- [tmux cheatsheet](./tmux-cheatsheet.md) — мультиплексор, в котором работает `--popup`, и источник конфликта по `Ctrl-B`
- [Git: модель данных, rebase против merge и спасательные инструменты](./git-guide.md) — `git log`, `git branch` и `git status` как генераторы кандидатов для `fzf`
