Atualização (13/09/2026): Depois da publicação deste artigo, um leitor testou o GoScope em um projeto Go real de grande porte — o cli/cli, com aproximadamente 5.800 funções — e encontrou uma limitação importante na forma como as declarações são indexadas.
Atualmente, as funções são indexadas apenas pelo nome, o que pode causar colisões e sobrescritas em projetos com múltiplos pacotes. O grafo de chamadas não é afetado por essa limitação e continua funcionando como descrito no artigo.
A correção será feita adicionando mais contexto à identidade das declarações para evitar essas colisões. Atualizarei este artigo quando ela estiver disponível.
Tem uma coisa que sempre me incomodou quando entro em um projeto Go grande (ou volto pra um código meu de seis meses atrás): eu sei que a função existe, sei mais ou menos o nome dela, mas não faço ideia de onde ela foi declarada nem de quem está chamando ela. grep resolve até certo ponto, mas em projetos com centenas de arquivos ele vira rapidamente um exercício de paciência — cheio de falsos positivos, comentários, strings e nomes parecidos.
Foi por causa disso que escrevi duas ferramentas de linha de comando: GoScope e rgs. A primeira varre um projeto Go inteiro e monta uma lista de tudo que foi declarado e tudo que foi chamado, com arquivo e linha. A segunda pega essa lista e transforma em algo navegável, usando fzf como filtro interativo. Juntas, elas resolvem exatamente o problema que eu tinha: ir do "eu sei que essa função existe" para "estou com o cursor em cima da declaração dela" em dois ou três segundos.
GoScope
O GoScope percorre recursivamente todos os arquivos .go a partir do diretório atual e imprime duas categorias de informação:
- Declarações: funções, variáveis, constantes e tipos, cada uma com o arquivo e a linha onde aparecem.
- Chamadas de função, no formato
Chamadora.Chamada arquivo:linha— ou seja, não só o que foi chamado, mas de dentro de qual função a chamada aconteceu.
Essa segunda parte é o que faz a ferramenta ser mais do que um ctags genérico: ela não te dá só "onde PrintNumber está definida", ela te dá "quem chama PrintNumber". É basicamente um grafo de chamadas simplificado, achatado em texto.
O uso é deliberadamente burro:
cd /path/do/seu/projeto
goscope
Sem parâmetros, sem flags para decorar. Ele sempre assume que você quer escanear o diretório atual e tudo abaixo dele. Isso foi uma escolha consciente: eu não queria ficar apontando subpasta por subpasta toda vez que trocasse de módulo dentro de um monorepo — o custo de digitar menos supera de longe a flexibilidade que eu perderia.
Por baixo do capô
Vale abrir essa caixa-preta, porque é o que separa uma ferramenta desse tipo de um grep glorificado. Analisar código Go "de verdade" — em vez de tratá-lo como texto puro — significa não confiar em expressões regulares para encontrar func, e sim parsear o arquivo para uma árvore sintática (AST) usando os pacotes go/parser e go/ast da própria standard library. Cada arquivo .go vira uma árvore de nós: declarações de função (*ast.FuncDecl), declarações genéricas de variáveis, constantes e tipos (*ast.GenDecl) e, dentro do corpo de cada função, expressões de chamada (*ast.CallExpr).
Andar nessa árvore com ast.Inspect é o que permite diferenciar, por exemplo, a palavra PrintNumber aparecendo dentro de um comentário ou de uma string literal — coisas que o grep trata como texto igual a qualquer outro — da palavra PrintNumber aparecendo de fato como uma chamada de função. É esse nível de precisão sintática que elimina o ruído e permite reconstruir, de forma confiável, o "quem chama quem" que aparece na saída.
A limitação natural dessa abordagem é que ela é estática: chamadas feitas via reflection, via interfaces resolvidas em tempo de execução, ou geradas dinamicamente, não aparecem no grafo — porque, sintaticamente, não existe uma CallExpr direta para acompanhar. Na prática, isso raramente incomoda no dia a dia, mas é bom saber que o mapa não é o território.
Exemplo de saída
Suponha um main.go com este conteúdo:
package main
import "fmt"
func main() {
fmt.Println("Olá mundo!")
PrintNumber(42)
}
func PrintNumber(n int) {
fmt.Printf("Number: %d\n", n)
}
Rodando goscope, a saída é algo como:
main main.go:5
PrintNumber main.go:10
main.Println main.go:6
main.PrintNumber main.go:7
PrintNumber.Printf main.go:11
Repare que main.PrintNumber main.go:7 já entrega, sem eu precisar procurar nada, que PrintNumber é chamada de dentro de main, na linha 7 — e não apenas onde PrintNumber foi declarada (linha 10). É essa dupla informação, declaração + ponto de chamada, que torna a lista útil como base de navegação.
rgs
O rgs usa o GoScope como fonte de dados e adiciona a parte interativa. O fluxo é:
- Executa
goscopepara coletar declarações e chamadas. - Alimenta essa lista para o
fzf, que funciona como um filtro fuzzy em tempo real. - Ao selecionar um item, abre o arquivo na linha exata usando o editor definido em
$EDITOR.
Para usar:
Digite parte do nome de uma função ou variável, escolha o resultado na lista e pressione Enter. Você cai direto no ponto do código — sem abrir o editor, procurar o arquivo e depois rolar até a linha certa manualmente. Em bases de código com centenas de funções espalhadas por dezenas de arquivos, essa economia de passos é o que faz a diferença entre "vou dar uma olhada rápida" e "vou perder dez minutos procurando".
Um detalhe que vale destacar no script: o preview usa bat --highlight-line, calculando uma janela de 10 linhas antes e depois do ponto selecionado. Isso significa que, antes mesmo de apertar Enter, você já vê o contexto ao redor da chamada ou da declaração — muitas vezes o suficiente para confirmar que é aquele o resultado certo, sem nem precisar abrir o arquivo.
#!/usr/bin/env bash
RCS_CMD="goscope"
INITIAL_QUERY="${*:-}"
selected=$(FZF_DEFAULT_COMMAND=$RCS_CMD \
fzf --ansi \
--bind "change:reload:sleep 0.1; $RCS_CMD || true" \
--delimiter ' ' \
--preview 'file=$(echo {2} | cut -d":" -f1); \
line=$(echo {2} | cut -d":" -f2); \
# Calcula o range de linhas: 10 linhas acima (se possível) e 10 abaixo.
start=$(( line > 10 ? line - 10 : 1 )); \
end=$(( line + 10 )); \
bat --color=always --highlight-line "$line" --line-range "$start:$end" "$file"' \
--preview-window 'up,60%,border-bottom' \
--query "$INITIAL_QUERY")
if [ -n "$selected" ]; then
IFS=' ' read -r function file_line <<< "$selected"
file="${file_line%%:*}"
line="${file_line#*:}"
$EDITOR "$file" "+$line"
echo "$file +$line"
fi
Esse script é só um ponto de partida. O --bind change:reload faz o goscope rodar de novo a cada tecla digitada, o que funciona bem em projetos pequenos e médios, mas em repositórios muito grandes pode valer a pena cachear a saída do GoScope em vez de re-escanear tudo a cada tecla — é uma otimização natural se você sentir o rgs ficar pesado.
Instalação
Compilando o GoScope:
go build -o goscope
mv goscope /usr/local/bin/
Instalando o rgs:
chmod +x rgs
cp rgs /usr/local/bin/
Depois disso, goscope e rgs funcionam de qualquer diretório do sistema.
Por que isso importa
GoScope e rgs não substituem um LSP nem um IDE completo — eles resolvem um problema mais específico e mais rápido: dar uma visão de raio-x de um projeto Go sem sair do terminal. Isso ajuda bastante em dois cenários que vejo com frequência: onboarding de gente nova no time, que ainda não tem mapa mental nenhum do código e precisa entender rápido "quem chama o quê"; e correção de bugs, quando você precisa rastrear o caminho de uma chamada até a origem sem abrir o projeto inteiro no editor.
Se você mexe com Go no dia a dia e sente essa mesma dor de não saber onde procurar, vale a pena testar — e adaptar o rgs para o seu próprio fluxo de trabalho.
