>_ josetilabs.com
← blog

18 out 2026 · linux, terminal, ferramentas · 6 min

O que muda no terminal quando o fzf assume a busca

Em 50 mil caminhos, a busca por migrcaes_hndler devolveu zero linha no grep e as 500 no fzf. Mesma lista, mesma máquina, dois caracteres fora do lugar: o grep exige o texto exato, o fzf casa as letras na ordem em que aparecem e ignora o que sobrou. É esse o motivo de o fzf existir, e é o que muda no seu dia a dia em três lugares.

A troca é boa e tem preço: filtrar 50 mil linhas levou 84 ms no fzf contra 2 ms do grep. Numa lista de histórico, ninguém sente essa diferença.

O script

O fzf-terminal.sh abaixo instala o fzf pelo gerenciador de pacotes, escreve as três integrações em um arquivo para bash e outro para zsh, e confere a sintaxe dos dois com bash -n e zsh -n. Sem fzf, sem git ou sem rede ele não trava: escreve o que dá, avisa o que faltou e sai com código 0.

bash
#!/usr/bin/env bash
# fzf-terminal.sh - instala o fzf e liga as tres integracoes que mudam o dia a dia:
# historico, arquivos e troca de branch.
#
# Uso:
#   ./fzf-terminal.sh                 instala o fzf e escreve a configuracao
#   ./fzf-terminal.sh --sem-instalar  so escreve a configuracao e roda a prova
#   ./fzf-terminal.sh --provar        so roda as tres buscas e mostra o resultado
#
# Sem fzf, sem git ou sem rede o script nao trava: ele escreve o que da para
# escrever, confere a sintaxe e diz o que faltou. Sai com codigo 0 nos caminhos.
set -u

MODO="${1:-instalar}"
CFG_BASH="$HOME/.fzf-terminal.bash"
CFG_ZSH="$HOME/.fzf-terminal.zsh"
DEMO="$HOME/.fzf-demo"

log() { printf '%s\n' "$*"; }
tem_cmd() { command -v "$1" >/dev/null 2>&1; }

instalar_pacotes() {
    local g="$1" pacotes="$2" sudo_cmd=""
    if [ "$(id -u)" -ne 0 ]; then
        if ! tem_cmd sudo; then return 1; fi
        sudo_cmd="sudo"
    fi
    case "$g" in
        apt-get)
            $sudo_cmd apt-get update -qq >/dev/null 2>&1
            # shellcheck disable=SC2086
            $sudo_cmd apt-get install -y --no-install-recommends $pacotes >/dev/null 2>&1 ;;
        dnf)     # shellcheck disable=SC2086
                $sudo_cmd dnf install -y $pacotes >/dev/null 2>&1 ;;
        pacman)  # shellcheck disable=SC2086
                $sudo_cmd pacman -S --noconfirm --needed $pacotes >/dev/null 2>&1 ;;
        apk)     # shellcheck disable=SC2086
                $sudo_cmd apk add --no-cache $pacotes >/dev/null 2>&1 ;;
        *) return 1 ;;
    esac
}

gerenciador() {
    local g
    for g in apt-get dnf pacman apk; do
        if tem_cmd "$g"; then printf '%s' "$g"; return 0; fi
    done
    return 1
}

instalar() {
    if tem_cmd fzf; then
        log "fzf: ja instalado ($(fzf --version))"
        return 0
    fi
    if ! g=$(gerenciador); then
        log "fzf: sem gerenciador de pacotes conhecido, pulei a instalacao"
        return 1
    fi
    log "fzf ausente: instalando com $g"
    # git entra junto porque a integracao de branch depende dele.
    if instalar_pacotes "$g" "fzf git"; then
        log "fzf e git instalados"
    fi
    if tem_cmd fzf; then
        log "fzf: $(fzf --version)"
        return 0
    fi
    log "fzf: nao instalado (sem root ou sem rede); o resto do script continua"
    return 1
}

# As tres integracoes. Tudo em funcao, para o mesmo arquivo servir em bash e zsh.
# --filter e o modo nao interativo do fzf: sem ele, sem terminal, nada aparece.
escrever_cfg() {
    {
        printf '# ~/.fzf-terminal.bash e ~/.fzf-terminal.zsh, gerados por fzf-terminal.sh\n'
        printf '# Va para o rc com: source ~/.fzf-terminal.$(basename "$SHELL")\n\n'

        printf '# 1. historico: busca por subtexto e devolve o comando na linha\n'
        printf 'fhist() {\n'
        printf '  local escolha\n'
        printf '  escolha=$(fc -lnr -1000 2>/dev/null | fzf --scheme=history --reverse --prompt="hist> ")\n'
        printf '  [ -z "$escolha" ] && return 0\n'
        printf '  # print -z so existe no zsh; no bash o comando volta pela READLINE_LINE\n'
        printf '  if [ -n "${ZSH_VERSION:-}" ]; then\n'
        printf '    print -z -- "$escolha"\n'
        printf '  fi\n'
        printf '  READLINE_LINE="$escolha"; READLINE_POINT=${#READLINE_LINE}\n'
        printf '}\n\n'

        printf '# 2. arquivos: fuzzy sobre find e abre no editor\n'
        printf 'ff() {\n'
        printf '  local arquivo\n'
        printf '  arquivo=$(find "${1:-.}" -type f -not -path "*/.git/*" 2>/dev/null | fzf --reverse --prompt="arquivo> ")\n'
        printf '  [ -z "$arquivo" ] && return 0\n'
        printf '  "${EDITOR:-vi}" "$arquivo"\n'
        printf '}\n\n'

        printf '# 3. branch: troca de branch a partir da lista\n'
        printf 'fb() {\n'
        printf '  local branch\n'
        printf '  command -v git >/dev/null 2>&1 || { echo "git nao instalado"; return 1; }\n'
        printf '  branch=$(git branch --format="%%(refname:short)" 2>/dev/null | fzf --reverse --prompt="branch> ")\n'
        printf '  [ -z "$branch" ] && return 0\n'
        printf '  git switch "$branch"\n'
        printf '}\n\n'

        printf '# Ctrl-R no historico\n'
        printf 'if [ -n "${ZSH_VERSION:-}" ]; then\n'
        printf '  zle -N fhist\n'
        printf '  bindkey "^R" fhist\n'
        printf 'else\n'
        printf '  bind -x "\\C-r": fhist 2>/dev/null\n'
        printf 'fi\n'
    } > "$CFG_BASH.tmp"

    {
        cat "$CFG_BASH.tmp"
        printf '\n# Ctrl-T abre o seletor de arquivos, que so o zsh faz com fzf\n'
        printf 'fzf-arquivo-widget() { ff; zle reset-prompt }\n'
        printf 'zle -N fzf-arquivo-widget\n'
        printf 'bindkey "^T" fzf-arquivo-widget\n'
    } > "$CFG_ZSH.tmp"

    mv "$CFG_BASH.tmp" "$CFG_BASH"
    mv "$CFG_ZSH.tmp" "$CFG_ZSH"
    chmod 600 "$CFG_BASH" "$CFG_ZSH"
    log "configuracao escrita: $CFG_BASH e $CFG_ZSH"
}

# Confere a sintaxe de verdade: bash -n e zsh -n leem o arquivo sem executar nada.
conferir_sintaxe() {
    local problema=0
    if tem_cmd bash; then
        if bash -n "$CFG_BASH" 2>/dev/null; then
            log "sintaxe do arquivo bash: ok"
        else
            log "sintaxe do arquivo bash: ERRO"
            problema=1
        fi
    fi
    if tem_cmd zsh; then
        if zsh -n "$CFG_ZSH" 2>/dev/null; then
            log "sintaxe do arquivo zsh: ok"
        else
            log "sintaxe do arquivo zsh: ERRO"
            problema=1
        fi
    fi
    return "$problema"
}

# Cenario de mentira: arquivos, historico e um repositorio com branches.
# Nada disso depende do que existe na maquina de quem roda.
montar_cenario() {
    local i
    mkdir -p "$DEMO"
    i=0
    while [ "$i" -lt 60 ]; do
        mkdir -p "$DEMO/modulo$((i % 6))"
        printf 'linha %d do arquivo de teste\n' "$i" > "$DEMO/modulo$((i % 6))/arquivo$i.txt"
        i=$((i + 1))
    done
    printf '%s\n' \
        'git status' \
        'systemctl restart nginx' \
        'tar xzf backup.tar.gz' \
        'docker ps -a' \
        'grep -rn timeout /etc' \
        'python manage.py migrate' \
        'systemctl status postgresql' \
        'tar czf arquivos.tar.gz notas' \
        'docker compose up -d' > "$DEMO/historico.txt"

    if ! tem_cmd git; then
        log "cenario: git ausente, o repositorio de teste nao foi criado"
        return 1
    fi
    mkdir -p "$DEMO/repo"
    (
        cd "$DEMO/repo" || exit 1
        git init -q 2>/dev/null
        git config user.email [email protected]
        git config user.name teste
        printf 'conteudo\n' > leia-me.txt
        git add leia-me.txt
        git commit -q -m "primeiro commit" 2>/dev/null
        for b in correcao-taxa nova-tela-manutencao correcao-critica; do
            git branch "$b" 2>/dev/null
        done
    )
    log "cenario montado: 60 arquivos, 9 comandos no historico, 4 branches"
    return 0
}

# As tres buscas no modo --filter, que e o mesmo motor do modo interativo sem
# a parte de desenhar a tela. E o que permite a prova rodar sem terminal.
provar() {
    if ! tem_cmd fzf; then
        log "prova: fzf ausente, nao ha o que filtrar"
        return 1
    fi
    log "fzf: $(fzf --version)"
    montar_cenario || true

    log ""
    log "== 1. historico: busca por 'systemclt', com o 's' fora do lugar =="
    log "grep achou $(grep -c 'systemclt' "$DEMO/historico.txt" 2>/dev/null) linha(s)"
    log "fzf achou:"
    fzf --scheme=history --filter='systemclt' < "$DEMO/historico.txt" | sed 's/^/  /'

    log ""
    log "== 2. arquivos: busca por 'aqui54', sem ponto e sem o v =="
    log "fzf achou:"
    find "$DEMO" -type f 2>/dev/null | fzf --filter='aqui54' | sed "s|$DEMO/||; s/^/  /"

    log ""
    if tem_cmd git && [ -d "$DEMO/repo/.git" ]; then
        log "== 3. branches: busca por 'corr' =="
        local achada
        achada=$( (cd "$DEMO/repo" && git branch --format='%(refname:short)' 2>/dev/null) | fzf --filter='corr' | head -n1 )
        log "fzf achou:"
        (cd "$DEMO/repo" && git branch --format='%(refname:short)' 2>/dev/null) | fzf --filter='corr' | sed 's/^/  /'
        log "primeira da lista, que e a que o fzf entregaria: ${achada:-nenhuma}"
    else
        log "== 3. branches: sem git, pulei a prova =="
    fi
    return 0
}

case "$MODO" in
    --provar)
        provar || true
        log "fim"
        exit 0
        ;;
    --sem-instalar)
        log "modo --sem-instalar: nada foi instalado"
        if tem_cmd fzf; then log "fzf presente: $(fzf --version)"; else log "fzf ausente"; fi
        if tem_cmd git; then log "git presente: $(git --version | cut -d' ' -f3)"; else log "git ausente"; fi
        ;;
    *)
        instalar || true
        ;;
esac

escrever_cfg
conferir_sintaxe || log "a configuracao precisa de revisao antes de ser usada"
provar || true

log "fim: acrescente source ~/.fzf-terminal.bash no seu rc e abra um terminal novo"
exit 0

Depois de rodar, acrescente source ~/.fzf-terminal.bash no seu ~/.bashrc (ou a versão .zsh no ~/.zshrc) e abra um terminal novo. O Ctrl-R passa a chamar o fhist, e as funções ff e fb ficam disponíveis.

Terminal com as três buscas do fzf: no histórico, a busca por systemclt devolve systemctl restart nginx e systemctl status postgresql; nos arquivos, a busca por aqui54 devolve modulo0/arquivo54.txt; nas branches, a busca por corr devolve correcao-taxa e correcao-critica
As três buscas com o resultado na tela. A primeira tem o `s` fora do lugar e mesmo assim acha o que o grep não acha.

Por que o grep não serve para isso

O grep procura substring. Se você não souber de cor quantos caracteres o caminho tem, se o separador é - ou _, se o número tem dois dígitos, a busca volta vazia e você não descobre se errou o termo ou se o arquivo não existe. O fzf casa as letras na ordem em que aparecem, sem exigir que fiquem coladas.

Gráfico de barras com o número de resultados em 50 mil caminhos: busca com o texto certo acha 500 no grep e 500 no fzf, e busca com dois caracteres fora do lugar acha 0 no grep e 500 no fzf
As duas primeiras barras empatam. A diferença toda está na terceira, que é zero.

Com o texto certo, os dois acham as mesmas 500 linhas. Com dois caracteres trocados, o grep acha 0 e o fzf acha as 500. A lista era a mesma nos quatro casos, com 50 mil caminhos e 500 deles contendo migracoes_handler.

O preço está no tempo. Filtrar as 50 mil linhas levou 2 ms no grep -c e 84 ms no fzf --filter, mediana de cinco execuções com uma de aquecimento. Para histórico de shell, esse segundo número não aparece na sua percepção. Para varrer um terabyte de log linha a linha, aparece.

O fzf casa letras, não sentido. Se a sua busca é por significado e não por nome, o caminho é outro, como mostra a busca por similaridade com sqlite-vec.

Como verificar

Rode ./fzf-terminal.sh. A saída de uma execução num contêiner ubuntu 24.04 foi esta:

text
fzf ausente: instalando com apt-get
fzf e git instalados
fzf: 0.44.1 (debian)
configuracao escrita: /root/.fzf-terminal.bash e /root/.fzf-terminal.zsh
sintaxe do arquivo bash: ok
fzf: 0.44.1 (debian)
cenario montado: 60 arquivos, 9 comandos no historico, 4 branches

== 1. historico: busca por 'systemclt', com o 's' fora do lugar ==
grep achou 0 linha(s)
fzf achou:
  systemctl status postgresql
  systemctl restart nginx

== 2. arquivos: busca por 'aqui54', sem ponto e sem o v ==
fzf achou:
  modulo0/arquivo54.txt

== 3. branches: busca por 'corr' ==
fzf achou:
  correcao-taxa
  correcao-critica
primeira da lista, que e a que o fzf entregaria: correcao-taxa
fim: acrescente source ~/.fzf-terminal.bash no seu rc e abra um terminal novo

grep achou 0 linha(s) seguido das duas linhas do systemctl é a prova de que a diferença é real e não impressão minha.

Se der errado

Failed to open /dev/tty ao chamar a função. Acontece quando você chama ff ou fb de um script, de um cron ou de um container, onde não existe terminal para o fzf desenhar. Nessas situações use fzf --filter, que imprime o resultado e sai. A função é feita para o prompt.

O Ctrl-R não faz nada depois do source. No bash o bind -x só vale em shell interativo; se você sorgou o arquivo de um script, nada é ligado. Rode o source no terminal e abra outra aba para conferir. A busca por branch devolve nada. A função usa git branch --format, que exige git 2.13 ou mais novo. Em git mais antigo, troque por git branch | cut -c3-.

fzf contra as alternativas

opção o que faz tempo em 50 mil linhas custo
grep substring exata 2 ms já vem na distro
fzf –filter fuzzy, não interativo 84 ms binário de 5 MB
skim fuzzy, mais rápido, em Rust não medido aqui precisa de nightly
bash completion completa o que já existe não se aplica já vem na distro

O skim promete ser mais rápido, mas exige a toolchain do Rust para compilar. O fzf é um binário só, e o git dele continua ativo: a versão 0.74.4 saiu em 12 de setembro de 2026.

FAQ

O fzf funciona sem shell interativo? A busca sim, com --filter. É assim que a prova deste post roda num contêiner sem terminal. O que exige terminal é a interface, com a lista desenhada embaixo e o Cursor.

Preciso de fzf para achar arquivo pelo nome? Não. find | grep resolve, e o ff é só para não decorar o caminho. Onde o fzf ganha mesmo é no histórico, onde você não lembra o comando exato.

Como faço o histórico contar mais comandos? O fc -lnr -1000 na função fhist lê as últimas mil linhas. Troque por fc -lnr -5000 se o seu histórico for maior, e ajuste o HISTSIZE do shell para caber.

Funciona no fish? Não direto. O fzf tem integração própria para fish, com fzf --fish | source, e a função de histórico precisa ser reescrita, porque o fc do fish é outro. O resto do raciocínio é igual.

Fontes

Medições em contêiner descartável ubuntu 24.04, com 0,5 CPU e 256 MB de RAM, fzf 0.44.1 do pacote da distro: lista de 50 mil caminhos gerada com awk, mediana de cinco execuções depois de uma de aquecimento. Detalhes de método e as contagens completas estão no post sobre containers sem root, que mediu do mesmo jeito.

Leia também

// Por e-mail

Os textos publicados aqui também vão por e-mail. O envio ainda não começou: quem deixar o endereço agora entra na primeira lista.

Guardo só o seu e-mail, a data e o registro desta autorização. Sem cookies, sem rastreador e nada compartilhado com terceiros. Detalhes na política de privacidade.