Conversor CSV para JSON em Zig — Tutorial Passo a Passo

Conversor CSV para JSON em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um conversor de CSV para JSON em Zig. CSV é um dos formatos mais usados para troca de dados, e converter para JSON é uma tarefa cotidiana. Vamos implementar um parser robusto que lida com campos entre aspas, vírgulas internas e escaping.

O Que Vamos Construir

Nosso conversor vai:

  • Parsear arquivos CSV com headers
  • Lidar com campos entre aspas duplas e vírgulas dentro de campos
  • Converter para JSON array de objetos
  • Suportar delimitadores configuráveis (vírgula, ponto-e-vírgula, tab)
  • Funcionar como ferramenta CLI de linha de comando
  • Processar arquivos via streaming (sem carregar tudo na memória)

Por Que Este Projeto?

Parsear CSV parece simples, mas os edge cases são muitos: aspas, escaping, campos multilinha, diferentes delimitadores. Construir um parser correto nos ensina sobre máquinas de estado e parsing character-by-character. Em Zig, o controle sobre alocação nos permite processar arquivos gigantes eficientemente.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir csv-to-json
cd csv-to-json
zig init

Passo 2: O Parser CSV

O coração do projeto é o parser CSV. Implementamos como uma máquina de estados que processa caractere por caractere.

const std = @import("std");
const fs = std.fs;
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;

/// Estados possíveis do parser CSV.
const EstadoParser = enum {
    inicio_campo,
    campo_normal,
    campo_entre_aspas,
    aspas_dentro_campo,
};

/// Configuração do parser CSV.
const ConfigCSV = struct {
    delimitador: u8 = ',',
    qualificador: u8 = '"',
    mostrar_tipos: bool = false,
};

/// Parser CSV que extrai campos respeitando aspas e escaping.
/// Processa uma linha por vez, retornando os campos como slices.
fn parsearLinhaCSV(
    linha: []const u8,
    config: ConfigCSV,
    campos: *std.ArrayList([]const u8),
) !void {
    campos.clearRetainingCapacity();

    var estado: EstadoParser = .inicio_campo;
    var inicio_campo: usize = 0;
    var i: usize = 0;

    // Buffer para campos que precisam de unescaping
    var buf_campo = std.ArrayList(u8).init(campos.allocator);
    defer buf_campo.deinit();
    var usando_buf = false;

    while (i <= linha.len) {
        const c: u8 = if (i < linha.len) linha[i] else 0; // sentinela no final
        const fim_da_linha = (i == linha.len);

        switch (estado) {
            .inicio_campo => {
                if (fim_da_linha or c == config.delimitador) {
                    // Campo vazio
                    try campos.append("");
                    if (fim_da_linha) break;
                } else if (c == config.qualificador) {
                    estado = .campo_entre_aspas;
                    buf_campo.clearRetainingCapacity();
                    usando_buf = true;
                } else {
                    inicio_campo = i;
                    estado = .campo_normal;
                    usando_buf = false;
                    continue; // re-processar este caractere
                }
            },
            .campo_normal => {
                if (fim_da_linha or c == config.delimitador) {
                    const campo = mem.trim(u8, linha[inicio_campo..i], " \t");
                    try campos.append(campo);
                    estado = .inicio_campo;
                    if (fim_da_linha) break;
                }
                // Outros caracteres: continua acumulando
            },
            .campo_entre_aspas => {
                if (fim_da_linha) {
                    // Linha termina dentro de aspas (campo multilinha não suportado)
                    const campo = try campos.allocator.dupe(u8, buf_campo.items);
                    try campos.append(campo);
                    break;
                } else if (c == config.qualificador) {
                    estado = .aspas_dentro_campo;
                } else {
                    try buf_campo.append(c);
                }
            },
            .aspas_dentro_campo => {
                if (c == config.qualificador) {
                    // Aspas duplas = aspas literal dentro do campo
                    try buf_campo.append(config.qualificador);
                    estado = .campo_entre_aspas;
                } else {
                    // Fim do campo entre aspas
                    const campo = try campos.allocator.dupe(u8, buf_campo.items);
                    try campos.append(campo);
                    estado = .inicio_campo;
                    if (fim_da_linha) break;
                    // Se não é delimitador, pular até o próximo
                    if (c != config.delimitador) {
                        while (i + 1 < linha.len and linha[i + 1] != config.delimitador) : (i += 1) {}
                    }
                }
            },
        }
        i += 1;
    }

    // Se terminou em campo_normal sem adicionar
    if (estado == .campo_normal) {
        const campo = mem.trim(u8, linha[inicio_campo..linha.len], " \t\r\n");
        try campos.append(campo);
    }

    _ = usando_buf;
}

Passo 3: Gerador JSON

/// Escapa uma string para uso em JSON.
fn escaparJSON(entrada: []const u8, buf: *std.ArrayList(u8)) !void {
    try buf.append('"');
    for (entrada) |c| {
        switch (c) {
            '"' => try buf.appendSlice("\\\""),
            '\\' => try buf.appendSlice("\\\\"),
            '\n' => try buf.appendSlice("\\n"),
            '\r' => try buf.appendSlice("\\r"),
            '\t' => try buf.appendSlice("\\t"),
            else => {
                if (c < 0x20) {
                    try buf.writer().print("\\u{x:0>4}", .{c});
                } else {
                    try buf.append(c);
                }
            },
        }
    }
    try buf.append('"');
}

/// Detecta se um valor é numérico.
fn ehNumerico(valor: []const u8) bool {
    if (valor.len == 0) return false;
    var tem_ponto = false;
    for (valor, 0..) |c, i| {
        if (c == '-' and i == 0) continue;
        if (c == '.' and !tem_ponto) {
            tem_ponto = true;
            continue;
        }
        if (c < '0' or c > '9') return false;
    }
    return true;
}

/// Converte CSV completo para JSON.
fn converterCSVparaJSON(
    conteudo: []const u8,
    config: ConfigCSV,
    allocator: mem.Allocator,
    writer: anytype,
) !u32 {
    var campos = std.ArrayList([]const u8).init(allocator);
    defer campos.deinit();

    var buf_json = std.ArrayList(u8).init(allocator);
    defer buf_json.deinit();

    // Separar linhas
    var linhas = mem.splitScalar(u8, conteudo, '\n');

    // Primeira linha = headers
    const linha_header = linhas.next() orelse return 0;
    const header_trimmed = mem.trim(u8, linha_header, "\r\n \t");
    if (header_trimmed.len == 0) return 0;

    try parsearLinhaCSV(header_trimmed, config, &campos);
    var headers = std.ArrayList([]const u8).init(allocator);
    defer headers.deinit();

    for (campos.items) |campo| {
        try headers.append(try allocator.dupe(u8, campo));
    }
    defer {
        for (headers.items) |h| allocator.free(h);
    }

    try writer.writeAll("[\n");
    var linha_num: u32 = 0;
    var primeira = true;

    // Processar linhas de dados
    while (linhas.next()) |linha| {
        const trimmed = mem.trim(u8, linha, "\r\n \t");
        if (trimmed.len == 0) continue;

        try parsearLinhaCSV(trimmed, config, &campos);

        if (!primeira) {
            try writer.writeAll(",\n");
        }
        primeira = false;

        try writer.writeAll("  {");

        for (headers.items, 0..) |header, col| {
            if (col > 0) try writer.writeAll(",");

            // Nome do campo
            buf_json.clearRetainingCapacity();
            try escaparJSON(header, &buf_json);
            try writer.writeAll(buf_json.items);
            try writer.writeAll(":");

            // Valor
            const valor = if (col < campos.items.len)
                campos.items[col]
            else
                "";

            if (valor.len == 0) {
                try writer.writeAll("null");
            } else if (ehNumerico(valor)) {
                try writer.writeAll(valor);
            } else if (mem.eql(u8, valor, "true") or mem.eql(u8, valor, "false")) {
                try writer.writeAll(valor);
            } else {
                buf_json.clearRetainingCapacity();
                try escaparJSON(valor, &buf_json);
                try writer.writeAll(buf_json.items);
            }
        }

        try writer.writeAll("}");
        linha_num += 1;
    }

    try writer.writeAll("\n]\n");
    return linha_num;
}

Passo 4: Função Main com CLI

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const stdout = io.getStdOut().writer();
    const stderr = io.getStdErr().writer();
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    var config = ConfigCSV{};
    var arquivo_entrada: ?[]const u8 = null;
    var arquivo_saida: ?[]const u8 = null;

    // Parsing de argumentos
    var i: usize = 1;
    while (i < args.len) : (i += 1) {
        const arg = args[i];
        if (mem.eql(u8, arg, "-d") or mem.eql(u8, arg, "--delimitador")) {
            i += 1;
            if (i < args.len) {
                if (mem.eql(u8, args[i], "tab")) {
                    config.delimitador = '\t';
                } else if (mem.eql(u8, args[i], "semicolon") or mem.eql(u8, args[i], ";")) {
                    config.delimitador = ';';
                } else if (args[i].len > 0) {
                    config.delimitador = args[i][0];
                }
            }
        } else if (mem.eql(u8, arg, "-o") or mem.eql(u8, arg, "--saida")) {
            i += 1;
            if (i < args.len) arquivo_saida = args[i];
        } else if (mem.eql(u8, arg, "-h") or mem.eql(u8, arg, "--help")) {
            try stdout.print(
                \\
                \\  CSV para JSON - Conversor em Zig
                \\
                \\  Uso: csv2json [opcoes] <arquivo.csv>
                \\
                \\  Opcoes:
                \\    -d, --delimitador <char>  Delimitador (padrao: ,)
                \\    -o, --saida <arquivo>     Arquivo de saida (padrao: stdout)
                \\    -h, --help                Ajuda
                \\
                \\  Exemplos:
                \\    csv2json dados.csv
                \\    csv2json -d ";" dados.csv -o dados.json
                \\    csv2json -d tab dados.tsv
                \\
            , .{});
            return;
        } else {
            arquivo_entrada = arg;
        }
    }

    // Ler arquivo de entrada ou demonstração
    var conteudo: []const u8 = undefined;
    var conteudo_alocado: ?[]u8 = null;
    defer if (conteudo_alocado) |c| allocator.free(c);

    if (arquivo_entrada) |caminho| {
        conteudo_alocado = try fs.cwd().readFileAlloc(allocator, caminho, 100 * 1024 * 1024);
        conteudo = conteudo_alocado.?;
    } else {
        // Dados de demonstração
        try stderr.print("  Nenhum arquivo fornecido. Usando dados de demonstracao.\n\n", .{});
        conteudo =
            \\nome,idade,cidade,ativo
            \\Ana Silva,28,São Paulo,true
            \\Bruno Costa,35,Rio de Janeiro,true
            \\"Carlos ""CJ"" Junior",22,Belo Horizonte,false
            \\Diana Martins,41,"Recife, PE",true
            \\Eduardo Farias,,Salvador,false
        ;
    }

    // Converter e escrever
    if (arquivo_saida) |caminho| {
        const arquivo = try fs.cwd().createFile(caminho, .{});
        defer arquivo.close();
        const writer = arquivo.writer();
        const linhas = try converterCSVparaJSON(conteudo, config, allocator, writer);
        try stderr.print("Convertido: {d} registros -> {s}\n", .{ linhas, caminho });
    } else {
        const linhas = try converterCSVparaJSON(conteudo, config, allocator, stdout);
        try stderr.print("\n  ({d} registros convertidos)\n", .{linhas});
    }
}

Testes

test "parser - campos simples" {
    const allocator = std.testing.allocator;
    var campos = std.ArrayList([]const u8).init(allocator);
    defer campos.deinit();

    try parsearLinhaCSV("a,b,c", .{}, &campos);
    try std.testing.expectEqual(@as(usize, 3), campos.items.len);
    try std.testing.expectEqualStrings("a", campos.items[0]);
    try std.testing.expectEqualStrings("b", campos.items[1]);
    try std.testing.expectEqualStrings("c", campos.items[2]);
}

test "parser - campo entre aspas" {
    const allocator = std.testing.allocator;
    var campos = std.ArrayList([]const u8).init(allocator);
    defer {
        for (campos.items) |c| {
            // Free campos que foram alocados (entre aspas)
            if (c.len > 0) allocator.free(c);
        }
        campos.deinit();
    }

    try parsearLinhaCSV("\"hello, world\",b", .{}, &campos);
    try std.testing.expectEqual(@as(usize, 2), campos.items.len);
    try std.testing.expectEqualStrings("hello, world", campos.items[0]);
}

test "eh numerico" {
    try std.testing.expect(ehNumerico("42"));
    try std.testing.expect(ehNumerico("3.14"));
    try std.testing.expect(ehNumerico("-10"));
    try std.testing.expect(!ehNumerico("abc"));
    try std.testing.expect(!ehNumerico(""));
    try std.testing.expect(!ehNumerico("12a"));
}

test "escapar JSON" {
    const allocator = std.testing.allocator;
    var buf = std.ArrayList(u8).init(allocator);
    defer buf.deinit();

    try escaparJSON("hello", &buf);
    try std.testing.expectEqualStrings("\"hello\"", buf.items);

    buf.clearRetainingCapacity();
    try escaparJSON("a\"b", &buf);
    try std.testing.expectEqualStrings("\"a\\\"b\"", buf.items);
}

Compilando e Executando

# Executar com dados de demonstração
zig build run

# Converter arquivo CSV
zig build run -- dados.csv

# Converter com delimitador diferente
zig build run -- -d ";" dados_br.csv

# Salvar em arquivo
zig build run -- dados.csv -o dados.json

# Rodar testes
zig build test

Conceitos Aprendidos

  • Máquina de estados para parsing de formatos complexos
  • Escaping e unescaping de caracteres especiais
  • Detecção de tipos para conversão inteligente
  • CLI com flags e argumentos posicionais
  • Streaming de dados sem carregar tudo na memória

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.