INI Parser em Zig — Tutorial Passo a Passo

INI Parser em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um parser de arquivos INI completo em Zig. Arquivos INI são um dos formatos de configuração mais simples e amplamente usados. Nosso parser vai ler, modificar e salvar configurações com suporte a seções, comentários e tipos de dados.

O Que Vamos Construir

Nosso INI parser vai:

  • Parsear arquivos INI com seções [nome], chaves chave=valor e comentários ; ...
  • Suportar valores string, inteiro, float e boolean
  • Permitir leitura e escrita de configurações
  • Preservar comentários ao salvar
  • Fornecer API ergonômica com getters tipados
  • Funcionar como biblioteca e como ferramenta CLI

Por Que Este Projeto?

Parsear formatos de configuração é uma habilidade essencial. O INI é simples o suficiente para um tutorial mas complexo o bastante para ensinar parsing de texto, gerenciamento de memória com HashMap e API design em Zig.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir ini-parser
cd ini-parser
zig init

Passo 2: Estrutura do Parser

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

/// Representa um valor em um arquivo INI.
/// Armazena tanto o valor raw (string) quanto permite
/// conversão tipada para inteiros, floats e booleans.
const IniValue = struct {
    raw: []const u8,
    comentario: ?[]const u8, // comentário inline, se houver

    pub fn asString(self: IniValue) []const u8 {
        return self.raw;
    }

    pub fn asInt(self: IniValue) ?i64 {
        return fmt.parseInt(i64, self.raw, 10) catch null;
    }

    pub fn asFloat(self: IniValue) ?f64 {
        return fmt.parseFloat(f64, self.raw) catch null;
    }

    pub fn asBool(self: IniValue) ?bool {
        if (mem.eql(u8, self.raw, "true") or mem.eql(u8, self.raw, "yes") or
            mem.eql(u8, self.raw, "1") or mem.eql(u8, self.raw, "on"))
            return true;
        if (mem.eql(u8, self.raw, "false") or mem.eql(u8, self.raw, "no") or
            mem.eql(u8, self.raw, "0") or mem.eql(u8, self.raw, "off"))
            return false;
        return null;
    }
};

/// Uma seção do arquivo INI, contendo pares chave-valor.
const IniSection = struct {
    valores: std.StringHashMap(IniValue),
    ordem: std.ArrayList([]const u8), // preserva ordem de inserção

    pub fn init(allocator: mem.Allocator) IniSection {
        return .{
            .valores = std.StringHashMap(IniValue).init(allocator),
            .ordem = std.ArrayList([]const u8).init(allocator),
        };
    }

    pub fn deinit(self: *IniSection, allocator: mem.Allocator) void {
        var it = self.valores.keyIterator();
        while (it.next()) |key| {
            allocator.free(key.*);
        }
        var vit = self.valores.valueIterator();
        while (vit.next()) |val| {
            allocator.free(val.raw);
            if (val.comentario) |c| allocator.free(c);
        }
        self.valores.deinit();
        self.ordem.deinit();
    }
};

/// Parser e armazenamento de configurações INI.
const IniConfig = struct {
    secoes: std.StringHashMap(IniSection),
    ordem_secoes: std.ArrayList([]const u8),
    comentarios_globais: std.ArrayList([]const u8),
    allocator: mem.Allocator,

    const Self = @This();

    pub fn init(allocator: mem.Allocator) Self {
        return .{
            .secoes = std.StringHashMap(IniSection).init(allocator),
            .ordem_secoes = std.ArrayList([]const u8).init(allocator),
            .comentarios_globais = std.ArrayList([]const u8).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *Self) void {
        var it = self.secoes.iterator();
        while (it.next()) |entry| {
            self.allocator.free(entry.key_ptr.*);
            entry.value_ptr.deinit(self.allocator);
        }
        self.secoes.deinit();
        self.ordem_secoes.deinit();
        for (self.comentarios_globais.items) |c| self.allocator.free(c);
        self.comentarios_globais.deinit();
    }

    /// Parseia o conteúdo de um arquivo INI.
    pub fn parsear(self: *Self, conteudo: []const u8) !void {
        var secao_atual: []const u8 = "default";

        // Garantir que a seção default existe
        if (!self.secoes.contains("default")) {
            const nome = try self.allocator.dupe(u8, "default");
            try self.secoes.put(nome, IniSection.init(self.allocator));
            try self.ordem_secoes.append(nome);
        }

        var linhas = mem.splitScalar(u8, conteudo, '\n');
        while (linhas.next()) |linha_raw| {
            const linha = mem.trim(u8, linha_raw, " \t\r");

            // Linha vazia
            if (linha.len == 0) continue;

            // Comentário
            if (linha[0] == ';' or linha[0] == '#') {
                const comentario = try self.allocator.dupe(u8, linha);
                try self.comentarios_globais.append(comentario);
                continue;
            }

            // Seção: [nome]
            if (linha[0] == '[') {
                if (mem.indexOf(u8, linha, "]")) |fim| {
                    const nome_secao = linha[1..fim];
                    secao_atual = nome_secao;

                    if (!self.secoes.contains(nome_secao)) {
                        const nome = try self.allocator.dupe(u8, nome_secao);
                        try self.secoes.put(nome, IniSection.init(self.allocator));
                        try self.ordem_secoes.append(nome);
                        secao_atual = nome;
                    }
                }
                continue;
            }

            // Chave = Valor
            if (mem.indexOf(u8, linha, "=")) |pos_igual| {
                const chave = mem.trim(u8, linha[0..pos_igual], " \t");
                var valor_str = mem.trim(u8, linha[pos_igual + 1 ..], " \t");

                // Verificar comentário inline
                var comentario_inline: ?[]const u8 = null;
                if (mem.indexOf(u8, valor_str, " ;")) |pos_com| {
                    comentario_inline = try self.allocator.dupe(u8, valor_str[pos_com + 1 ..]);
                    valor_str = mem.trim(u8, valor_str[0..pos_com], " \t");
                }

                // Remover aspas
                if (valor_str.len >= 2 and valor_str[0] == '"' and valor_str[valor_str.len - 1] == '"') {
                    valor_str = valor_str[1 .. valor_str.len - 1];
                }

                if (self.secoes.getPtr(secao_atual)) |secao| {
                    const chave_copia = try self.allocator.dupe(u8, chave);
                    const valor_copia = try self.allocator.dupe(u8, valor_str);

                    try secao.valores.put(chave_copia, .{
                        .raw = valor_copia,
                        .comentario = comentario_inline,
                    });
                    try secao.ordem.append(chave_copia);
                }
            }
        }
    }

    /// Carrega um arquivo INI do disco.
    pub fn carregarArquivo(self: *Self, caminho: []const u8) !void {
        const conteudo = try fs.cwd().readFileAlloc(self.allocator, caminho, 10 * 1024 * 1024);
        defer self.allocator.free(conteudo);
        try self.parsear(conteudo);
    }

    /// Obtém um valor de uma seção.
    pub fn get(self: *const Self, secao: []const u8, chave: []const u8) ?IniValue {
        if (self.secoes.get(secao)) |sec| {
            return sec.valores.get(chave);
        }
        return null;
    }

    /// Obtém um valor como string com fallback padrão.
    pub fn getString(self: *const Self, secao: []const u8, chave: []const u8, padrao: []const u8) []const u8 {
        if (self.get(secao, chave)) |val| return val.asString();
        return padrao;
    }

    /// Obtém um valor como inteiro com fallback padrão.
    pub fn getInt(self: *const Self, secao: []const u8, chave: []const u8, padrao: i64) i64 {
        if (self.get(secao, chave)) |val| {
            if (val.asInt()) |i| return i;
        }
        return padrao;
    }

    /// Obtém um valor como boolean com fallback padrão.
    pub fn getBool(self: *const Self, secao: []const u8, chave: []const u8, padrao: bool) bool {
        if (self.get(secao, chave)) |val| {
            if (val.asBool()) |b| return b;
        }
        return padrao;
    }

    /// Serializa a configuração de volta para formato INI.
    pub fn serializar(self: *const Self, writer: anytype) !void {
        // Comentários globais
        for (self.comentarios_globais.items) |c| {
            try writer.print("{s}\n", .{c});
        }

        for (self.ordem_secoes.items) |nome_secao| {
            if (self.secoes.get(nome_secao)) |secao| {
                if (!mem.eql(u8, nome_secao, "default")) {
                    try writer.print("\n[{s}]\n", .{nome_secao});
                }

                for (secao.ordem.items) |chave| {
                    if (secao.valores.get(chave)) |valor| {
                        try writer.print("{s} = {s}", .{ chave, valor.raw });
                        if (valor.comentario) |com| {
                            try writer.print(" {s}", .{com});
                        }
                        try writer.print("\n", .{});
                    }
                }
            }
        }
    }

    /// Salva a configuração em um arquivo.
    pub fn salvarArquivo(self: *const Self, caminho: []const u8) !void {
        const arquivo = try fs.cwd().createFile(caminho, .{});
        defer arquivo.close();
        try self.serializar(arquivo.writer());
    }

    /// Lista todas as seções.
    pub fn listSecoes(self: *const Self) []const []const u8 {
        return self.ordem_secoes.items;
    }
};

Passo 3: 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 args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    var config = IniConfig.init(allocator);
    defer config.deinit();

    if (args.len >= 2 and !mem.startsWith(u8, args[1], "--")) {
        // Carregar arquivo
        config.carregarArquivo(args[1]) catch |err| {
            try stdout.print("Erro ao carregar '{s}': {}\n", .{ args[1], err });
            return;
        };
        try stdout.print("Arquivo '{s}' carregado.\n\n", .{args[1]});
    } else {
        // Demonstração com INI de exemplo
        const exemplo =
            \\; Configuração do servidor web
            \\; Gerado pelo INI Parser em Zig
            \\
            \\[servidor]
            \\host = 0.0.0.0
            \\porta = 8080
            \\workers = 4
            \\debug = false
            \\
            \\[banco_de_dados]
            \\driver = postgresql
            \\host = localhost
            \\porta = 5432
            \\nome = meu_app
            \\usuario = admin
            \\max_conexoes = 20
            \\
            \\[log]
            \\nivel = info ; niveis: debug, info, warn, error
            \\arquivo = /var/log/app.log
            \\rotacao = true
            \\max_tamanho_mb = 100
        ;

        try config.parsear(exemplo);
        try stdout.print("  Configuracao de exemplo carregada.\n\n", .{});
    }

    // Exibir configuração parseada
    try stdout.print("  ==========================================\n", .{});
    try stdout.print("     INI PARSER - Zig\n", .{});
    try stdout.print("  ==========================================\n\n", .{});

    for (config.listSecoes()) |secao| {
        try stdout.print("  [{s}]\n", .{secao});
        if (config.secoes.get(secao)) |sec| {
            for (sec.ordem.items) |chave| {
                if (sec.valores.get(chave)) |valor| {
                    try stdout.print("    {s} = {s}", .{ chave, valor.raw });
                    // Detectar tipo
                    if (valor.asInt() != null) {
                        try stdout.print("  (int)", .{});
                    } else if (valor.asBool() != null) {
                        try stdout.print("  (bool)", .{});
                    } else {
                        try stdout.print("  (string)", .{});
                    }
                    try stdout.print("\n", .{});
                }
            }
        }
        try stdout.print("\n", .{});
    }

    // Demonstrar API tipada
    try stdout.print("  --- API Tipada ---\n", .{});
    try stdout.print("  servidor.porta (int):   {d}\n", .{config.getInt("servidor", "porta", 3000)});
    try stdout.print("  servidor.debug (bool):  {}\n", .{config.getBool("servidor", "debug", false)});
    try stdout.print("  banco_de_dados.driver:  {s}\n", .{config.getString("banco_de_dados", "driver", "sqlite")});
    try stdout.print("  log.nivel:              {s}\n", .{config.getString("log", "nivel", "warn")});

    // Serializar de volta
    try stdout.print("\n  --- Serializado ---\n", .{});
    try config.serializar(stdout);
}

Testes

test "parsear seção e chave simples" {
    const allocator = std.testing.allocator;
    var config = IniConfig.init(allocator);
    defer config.deinit();

    try config.parsear("[teste]\nchave = valor\n");

    const val = config.get("teste", "chave");
    try std.testing.expect(val != null);
    try std.testing.expectEqualStrings("valor", val.?.asString());
}

test "tipos de valor" {
    const allocator = std.testing.allocator;
    var config = IniConfig.init(allocator);
    defer config.deinit();

    try config.parsear(
        \\[tipos]
        \\inteiro = 42
        \\decimal = 3.14
        \\booleano = true
        \\texto = hello world
    );

    try std.testing.expectEqual(@as(i64, 42), config.getInt("tipos", "inteiro", 0));
    try std.testing.expect(config.getBool("tipos", "booleano", false));
    try std.testing.expectEqualStrings("hello world", config.getString("tipos", "texto", ""));
}

test "fallback padrao" {
    const allocator = std.testing.allocator;
    var config = IniConfig.init(allocator);
    defer config.deinit();

    try config.parsear("[s]\nk = v\n");

    try std.testing.expectEqual(@as(i64, 99), config.getInt("s", "inexistente", 99));
    try std.testing.expectEqualStrings("padrao", config.getString("s", "inexistente", "padrao"));
}

test "IniValue conversoes" {
    const val = IniValue{ .raw = "true", .comentario = null };
    try std.testing.expect(val.asBool().? == true);

    const num = IniValue{ .raw = "42", .comentario = null };
    try std.testing.expectEqual(@as(i64, 42), num.asInt().?);
}

Compilando e Executando

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

# Parsear um arquivo INI existente
zig build run -- config.ini

# Rodar testes
zig build test

Conceitos Aprendidos

  • Parsing de texto linha por linha com máquina de estados
  • HashMap aninhado (seções contendo HashMaps de valores)
  • API tipada com conversão segura e fallbacks
  • Serialização preservando formato e comentários
  • Gerenciamento de memória com ownership de strings alocadas

Próximos Passos

Continue aprendendo Zig

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