Bem-vindo à aula 87! Nesta aula, vamos consolidar os conhecimentos adquiridos ao longo do curso, construindo uma ferramenta de linha de comando (CLI) completa em Go. Uma CLI é um programa que roda no terminal, recebendo argumentos e produzindo saída textual. Vamos explorar os pilares de qualquer CLI: o parsing de argumentos, a leitura de arquivos, a saída formatada e, claro, a escrita de testes para garantir a qualidade do código.

Nosso projeto será um utilitário de análise de texto: ele receberá um arquivo de texto e opções como -palavras, -linhas ou -caracteres, e exibirá estatísticas sobre o conteúdo. Esse exemplo prático toca vários pontos importantes da linguagem e da biblioteca padrão, tornando o aprendizado concreto.

Parsing de argumentos

Em Go, a forma mais comum de processar argumentos de linha de comando é usando o pacote flag, que faz parte da biblioteca padrão. Ele permite definir flags (opções) e argumentos posicionais. Vamos começar com um exemplo simples: um programa que aceita uma flag -nome e um argumento posicional (um arquivo).

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    // Define uma flag chamada "nome" com valor padrão "mundo" e descrição
    nome := flag.String("nome", "mundo", "nome para saudação")
    // Faz o parsing dos argumentos
    flag.Parse()

    // Argumentos posicionais restantes (após as flags)
    args := flag.Args()
    if len(args) == 0 {
        fmt.Println("Uso: meuapp [flags] arquivo")
        os.Exit(1)
    }

    arquivo := args[0]
    fmt.Printf("Olá, %s! Arquivo: %s\n", *nome, arquivo)
}

No exemplo, criamos uma flag do tipo string com flag.String, que retorna um ponteiro para o valor. Após flag.Parse(), as flags são processadas e os argumentos posicionais ficam disponíveis em flag.Args(). Note que precisamos desreferenciar o ponteiro para obter o valor da flag.

Para o nosso projeto, vamos definir flags como -palavras, -linhas e -caracteres, que serão booleanas (presença indica que queremos aquela estatística). Além disso, podemos ter uma flag -o para especificar um arquivo de saída, caso o usuário queira salvar o resultado em vez de imprimir na tela.

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    // Flags booleanas: se presentes, ativam a contagem
    contarPalavras := flag.Bool("palavras", false, "contar palavras")
    contarLinhas := flag.Bool("linhas", false, "contar linhas")
    contarCaracteres := flag.Bool("caracteres", false, "contar caracteres")
    // Flag para arquivo de saída opcional
    saidaArquivo := flag.String("o", "", "arquivo de saída (opcional)")

    flag.Parse()

    // Verifica se pelo menos uma flag foi fornecida
    if !*contarPalavras && !*contarLinhas && !*contarCaracteres {
        fmt.Fprintln(os.Stderr, "Erro: especifique pelo menos uma estatística (-palavras, -linhas, -caracteres)")
        flag.Usage()
        os.Exit(1)
    }

    // Obtém o arquivo de entrada
    args := flag.Args()
    if len(args) != 1 {
        fmt.Fprintln(os.Stderr, "Uso: analisador [flags] arquivo")
        os.Exit(1)
    }
    arquivoEntrada := args[0]

    // ... resto da implementação
}

Uma boa prática é usar flag.Usage para exibir mensagens de ajuda personalizadas. Podemos substituir a função padrão por uma que explique melhor o uso do programa.

Leitura de arquivos

Para ler o conteúdo de um arquivo, temos várias opções em Go. A mais simples é usar os.ReadFile, que lê o arquivo inteiro para a memória. Isso é adequado para arquivos de tamanho moderado. Para arquivos grandes, seria melhor usar bufio.Scanner linha por linha, economizando memória.

No nosso caso, como precisamos contar palavras, linhas e caracteres, podemos ler o arquivo inteiro ou processá-lo linha por linha. Vamos optar por ler o conteúdo completo para simplificar a contagem de caracteres e palavras.

conteudo, err := os.ReadFile(arquivoEntrada)
if err != nil {
    fmt.Fprintf(os.Stderr, "Erro ao ler arquivo: %v\n", err)
    os.Exit(1)
}
texto := string(conteudo)

Uma alternativa mais robusta é usar os.Open e depois bufio.NewReader ou bufio.Scanner. Isso permite ler arquivos linha a linha e é útil quando queremos processar grandes volumes sem carregar tudo na memória. No exemplo a seguir, mostramos como ler um arquivo linha por linha, o que pode ser útil se quisermos contar linhas separadamente.

arquivo, err := os.Open(arquivoEntrada)
if err != nil {
    fmt.Fprintf(os.Stderr, "Erro ao abrir arquivo: %v\n", err)
    os.Exit(1)
}
defer arquivo.Close()

scanner := bufio.NewScanner(arquivo)
linhas := 0
for scanner.Scan() {
    linhas++
    // Processar cada linha, se necessário
}
if err := scanner.Err(); err != nil {
    fmt.Fprintf(os.Stderr, "Erro ao ler arquivo: %v\n", err)
}

No nosso projeto, vamos usar os.ReadFile para simplificar, mas vamos também demonstrar o uso do scanner para contar linhas. Na verdade, para contar linhas, podemos simplesmente usar strings.Count(texto, "\n") ou dividir o texto em linhas com strings.Split, mas cuidado com a última linha sem quebra. Uma função robusta de contagem de linhas pode ser:

func contarLinhas(texto string) int {
    if texto == "" {
        return 0
    }
    return strings.Count(texto, "\n") + 1 // +1 para a última linha sem newline
}

Essa abordagem é eficiente e simples.

Saída

A saída de uma CLI pode ser direcionada para a tela (stdout) ou para um arquivo. Em Go, usamos fmt.Println, fmt.Printf e fmt.Fprintf para escrever em diferentes destinos. Vamos criar uma função que formata os resultados de forma clara e amigável.

Para o nosso analisador, queremos exibir as estatísticas solicitadas. Podemos imprimir cada uma em uma linha, precedida por um rótulo. Se o usuário passou a flag -o, escrevemos em um arquivo; caso contrário, usamos stdout.

func gerarResultado(texto string, contarPalavras, contarLinhas, contarCaracteres bool) string {
    var resultado strings.Builder
    if contarPalavras {
        palavras := contarPalavrasTexto(texto)
        fmt.Fprintf(&resultado, "Palavras: %d\n", palavras)
    }
    if contarLinhas {
        linhas := contarLinhas(texto)
        fmt.Fprintf(&resultado, "Linhas: %d\n", linhas)
    }
    if contarCaracteres {
        caracteres := len([]rune(texto)) // considera caracteres unicode
        fmt.Fprintf(&resultado, "Caracteres: %d\n", caracteres)
    }
    return resultado.String()
}

Para contar palavras, podemos usar strings.Fields, que divide a string por espaços em branco e retorna uma fatia de palavras. O número de palavras é o comprimento dessa fatia.

func contarPalavrasTexto(texto string) int {
    palavras := strings.Fields(texto)
    return len(palavras)
}

No main, depois de gerar o resultado, decidimos o destino:

resultado := gerarResultado(texto, *contarPalavras, *contarLinhas, *contarCaracteres)

if *saidaArquivo != "" {
    err := os.WriteFile(*saidaArquivo, []byte(resultado), 0644)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Erro ao escrever arquivo de saída: %v\n", err)
        os.Exit(1)
    }
} else {
    fmt.Print(resultado)
}

É importante usar len([]rune(texto)) em vez de len(texto) para contar caracteres corretamente quando há caracteres multibyte (como acentos ou emojis), pois len retorna o número de bytes.

Testes

Testar é fundamental para garantir que nossa CLI funcione corretamente. Em Go, escrevemos testes em arquivos com sufixo _test.go e usamos o pacote testing. Vamos criar testes para as funções auxiliares, como contarLinhas, contarPalavrasTexto e gerarResultado.

Primeiro, vamos separar as funções lógicas em um arquivo, por exemplo analisador.go, para podermos testá-las isoladamente. O main ficará em outro arquivo, como main.go.

Exemplo de teste para contarPalavrasTexto:

package main

import "testing"

func TestContarPalavrasTexto(t *testing.T) {
    testes := []struct {
        nome     string
        texto    string
        esperado int
    }{
        {"texto vazio", "", 0},
        {"uma palavra", "olá", 1},
        {"frase simples", "Olá, mundo!", 2},
        {"texto com múltiplos espaços", "  Go    é   incrível  ", 3},
    }

    for _, tt := range testes {
        t.Run(tt.nome, func(t *testing.T) {
            resultado := contarPalavrasTexto(tt.texto)
            if resultado != tt.esperado {
                t.Errorf("contarPalavrasTexto(%q) = %d; esperado %d", tt.texto, resultado, tt.esperado)
            }
        })
    }
}

Da mesma forma, testamos contarLinhas com casos que incluam quebras de linha no final ou não:

func TestContarLinhas(t *testing.T) {
    testes := []struct {
        nome     string
        texto    string
        esperado int
    }{
        {"texto vazio", "", 0},
        {"uma linha sem newline", "linha única", 1},
        {"duas linhas com newline final", "linha1\nlinha2\n", 2},
        {"duas linhas sem newline final", "linha1\nlinha2", 2},
    }

    for _, tt := range testes {
        t.Run(tt.nome, func(t *testing.T) {
            resultado := contarLinhas(tt.texto)
            if resultado != tt.esperado {
                t.Errorf("contarLinhas(%q) = %d; esperado %d", tt.texto, resultado, tt.esperado)
            }
        })
    }
}

Além dos testes unitários, podemos testar a integração do programa usando os/exec para executar o binário e verificar a saída. Isso é um pouco mais avançado, mas vamos incluir um teste simples para o fluxo completo:

func TestMainFluxo(t *testing.T) {
    // Cria um arquivo temporário com conteúdo conhecido
    dir := t.TempDir()
    arquivo := filepath.Join(dir, "teste.txt")
    conteudo := "Olá, mundo!\nEsta é uma linha.\n"
    err := os.WriteFile(arquivo, []byte(conteudo), 0644)
    if err != nil {
        t.Fatal(err)
    }

    // Executa o programa com as flags -palavras e -linhas
    cmd := exec.Command("go", "run", ".", "-palavras", "-linhas", arquivo)
    saida, err := cmd.CombinedOutput()
    if err != nil {
        t.Fatalf("erro ao executar: %v", err)
    }

    // Verifica se a saída contém as contagens esperadas
    esperadoPalavras := "Palavras: 6" // palavras: Olá, mundo! Esta é uma linha.
    esperadoLinhas := "Linhas: 2"
    if !strings.Contains(string(saida), esperadoPalavras) {
        t.Errorf("saída não contém %q; saída: %s", esperadoPalavras, saida)
    }
    if !strings.Contains(string(saida), esperadoLinhas) {
        t.Errorf("saída não contém %q; saída: %s", esperadoLinhas, saida)
    }
}

Esse teste garante que o programa compila e funciona corretamente para um caso simples. Lembre-se de que, para executar testes que usam go run, o diretório atual deve ser o diretório do pacote.

Boas práticas e observações finais

Ao desenvolver CLIs em Go, algumas boas práticas incluem: usar o pacote flag para consistência, fornecer mensagens de erro claras no stderr, retornar códigos de saída apropriados (0 para sucesso, 1 para erro), e separar a lógica de negócios das funções de I/O para facilitar testes. Além disso, considere usar context para cancelamento e timeouts em operações longas, e explore bibliotecas como cobra para CLIs mais complexas, mas lembre-se de que a biblioteca padrão é suficiente para muitos casos.

Outra dica: ao contar caracteres, use utf8.RuneCountInString para obter o número de runas (caracteres visíveis), que é mais eficiente do que converter para []rune.

Por fim, documente seu código com comentários e forneça uma boa mensagem de ajuda com flag.Usage. Testes bem escritos são um investimento que economiza tempo no futuro.

Referências

Exercícios

  1. Exercício 1: Crie uma função chamada contarCaracteres que recebe uma string e retorna o número de caracteres (runas). Teste com strings contendo acentos e emojis.
  2. ✓ Resposta:
    func contarCaracteres(texto string) int {
        return utf8.RuneCountInString(texto)
    }
    // Teste: contarCaracteres("Olá, mundo!") deve retornar 12 (incluindo espaços e pontuação)
    // Teste com emoji: contarCaracteres("😀") retorna 1.
    
  3. Exercício 2: Adicione uma flag -m ao programa que, quando presente, imprime o resultado em formato JSON. Dica: use encoding/json.
  4. ✓ Resposta:
    // No main, defina a flag:
    jsonSaida := flag.Bool("json", false, "saída em JSON")
    // Depois de gerar as estatísticas, monte um map ou struct e use json.MarshalIndent.
    // Exemplo:
    tipoResultado := struct {
        Palavras    int `json:"palavras,omitempty"`
        Linhas      int `json:"linhas,omitempty"`
        Caracteres  int `json:"caracteres,omitempty"`
    }{}
    if *contarPalavras { tipoResultado.Palavras = contarPalavrasTexto(texto) }
    if *contarLinhas { tipoResultado.Linhas = contarLinhas(texto) }
    if *contarCaracteres { tipoResultado.Caracteres = utf8.RuneCountInString(texto) }
    if *jsonSaida {
        saida, _ := json.MarshalIndent(tipoResultado, "", "  ")
        fmt.Println(string(saida))
    } else {
        // saída normal
    }
    
  5. Exercício 3: Modifique o programa para aceitar múltiplos arquivos de entrada e exibir estatísticas separadas para cada um, além de um total combinado.
  6. ✓ Resposta:
    // Em vez de exigir um único arquivo, itere sobre flag.Args().
    // Para cada arquivo, leia e calcule as estatísticas, imprimindo o nome do arquivo.
    // Acumule totais para uma seção final "Total".
    // Exemplo de estrutura:
    for _, arquivo := range flag.Args() {
        conteudo, err := os.ReadFile(arquivo)
        if err != nil { /* tratar */ }
        texto := string(conteudo)
        fmt.Printf("== %s ==\n", arquivo)
        if *contarPalavras { fmt.Printf("Palavras: %d\n", contarPalavrasTexto(texto)) }
        // ...
        // Acumule totais
    }
    // Imprima totais se houver mais de um arquivo.
    
  7. Exercício 4: Escreva um teste para a função contarLinhas que verifique o caso de texto vazio, uma linha sem newline, e duas linhas com e sem newline final.
  8. ✓ Resposta:
    func TestContarLinhas(t *testing.T) {
        testes := []struct {
            nome  string
            texto string
            esperado int
        }{
            {"vazio", "", 0},
            {"uma linha sem newline", "abc", 1},
            {"duas com newline final", "a\nb\n", 2},
            {"duas sem newline final", "a\nb", 2},
        }
        for _, tt := range testes {
            t.Run(tt.nome, func(t *testing.T) {
                got := contarLinhas(tt.texto)
                if got != tt.esperado {
                    t.Errorf("contarLinhas(%q) = %d, esperado %d", tt.texto, got, tt.esperado)
                }
            })
        }
    }
    
  9. Exercício 5: Implemente uma flag -v (verbose) que imprime informações adicionais, como o nome do arquivo, tamanho em bytes e tempo de processamento.
  10. ✓ Resposta:
    // Defina: verbose := flag.Bool("v", false, "modo verboso")
    // No main, meça o tempo com time.Now() antes e depois do processamento.
    // Se *verbose, imprima detalhes:
    if *verbose {
        info, _ := os.Stat(arquivoEntrada)
        fmt.Printf("Arquivo: %s (%d bytes)\n", arquivoEntrada, info.Size())
        fmt.Printf("Processado em %v\n", time.Since(inicio))
    }