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
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com I/O de arquivos
- Conhecimento de slices e strings
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
- Explore I/O de arquivos para mais formatos
- Aprenda sobre strings e parsing na stdlib
- Veja o projeto INI Parser para outro formato
- Construa o JSON Config Parser para JSON