Cron Parser em Zig — Tutorial Passo a Passo

Cron Parser em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um parser de expressões cron em Zig. Cron é o padrão Unix para agendamento de tarefas, e parsear suas expressões envolve lidar com ranges, steps, wildcards e validação de campos.

O Que Vamos Construir

Nosso cron parser vai:

  • Parsear expressões cron padrão de 5 campos (minuto, hora, dia, mês, dia da semana)
  • Suportar wildcards (*), ranges (1-5), listas (1,3,5) e steps (*/15)
  • Validar os limites de cada campo
  • Calcular as próximas N execuções a partir de um horário
  • Exibir descrição legível em português da expressão
  • Fornecer interface CLI interativa

Por Que Este Projeto?

Expressões cron parecem simples mas escondem complexidade: combinações de ranges com steps, interação entre campos, e cálculo correto da próxima execução considerando meses com dias variáveis. É um excelente exercício de parsing e lógica de data/hora.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir cron-parser
cd cron-parser
zig init

Passo 2: Estrutura de um Campo Cron

Cada campo de uma expressão cron pode conter múltiplas especificações. Representamos cada especificação como um enum que cobre todos os casos possíveis.

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

/// Tipos de especificação de um campo cron.
const CronSpec = union(enum) {
    /// Qualquer valor (*).
    qualquer: void,
    /// Valor exato (ex: 5).
    exato: u8,
    /// Range de valores (ex: 1-5).
    range: struct { inicio: u8, fim: u8 },
    /// Step/intervalo (ex: */15 ou 1-30/5).
    step: struct { base: CronBase, passo: u8 },
};

/// Base para steps: pode ser "qualquer" ou um range.
const CronBase = union(enum) {
    qualquer: void,
    range: struct { inicio: u8, fim: u8 },
};

/// Representa um campo completo (pode ter múltiplas specs separadas por vírgula).
const CronField = struct {
    specs: [16]CronSpec, // máximo de 16 especificações por campo
    count: u8,
    min_val: u8,
    max_val: u8,

    /// Verifica se um valor corresponde a este campo.
    pub fn corresponde(self: *const CronField, valor: u8) bool {
        for (self.specs[0..self.count]) |spec| {
            if (correspondeSpec(spec, valor, self.min_val, self.max_val)) return true;
        }
        return false;
    }
};

fn correspondeSpec(spec: CronSpec, valor: u8, min_val: u8, max_val: u8) bool {
    switch (spec) {
        .qualquer => return true,
        .exato => |v| return valor == v,
        .range => |r| return valor >= r.inicio and valor <= r.fim,
        .step => |s| {
            const base_inicio = switch (s.base) {
                .qualquer => min_val,
                .range => |r| r.inicio,
            };
            const base_fim = switch (s.base) {
                .qualquer => max_val,
                .range => |r| r.fim,
            };
            if (valor < base_inicio or valor > base_fim) return false;
            return (valor - base_inicio) % s.passo == 0;
        },
    }
}

/// Expressão cron completa com 5 campos.
const CronExpression = struct {
    minuto: CronField,    // 0-59
    hora: CronField,      // 0-23
    dia_mes: CronField,   // 1-31
    mes: CronField,       // 1-12
    dia_semana: CronField, // 0-6 (0=domingo)
    raw: [128]u8,
    raw_len: usize,
};

/// Erros de parsing de cron.
const CronError = error{
    CampoInvalido,
    ValorForaDoRange,
    ExpressaoIncompleta,
    FormatoInvalido,
};

Passo 3: O Parser

/// Parseia um campo individual de uma expressão cron.
fn parsearCampo(campo_str: []const u8, min_val: u8, max_val: u8) CronError!CronField {
    var field = CronField{
        .specs = undefined,
        .count = 0,
        .min_val = min_val,
        .max_val = max_val,
    };

    // Dividir por vírgulas (listas)
    var it = mem.splitScalar(u8, campo_str, ',');
    while (it.next()) |parte| {
        if (field.count >= 16) return CronError.CampoInvalido;

        // Verificar se tem step (/)
        if (mem.indexOf(u8, parte, "/")) |pos_barra| {
            const base_str = parte[0..pos_barra];
            const passo_str = parte[pos_barra + 1 ..];
            const passo = fmt.parseInt(u8, passo_str, 10) catch return CronError.FormatoInvalido;
            if (passo == 0) return CronError.ValorForaDoRange;

            var base: CronBase = undefined;
            if (mem.eql(u8, base_str, "*")) {
                base = .qualquer;
            } else if (mem.indexOf(u8, base_str, "-")) |pos_hifen| {
                const inicio = fmt.parseInt(u8, base_str[0..pos_hifen], 10) catch return CronError.FormatoInvalido;
                const fim = fmt.parseInt(u8, base_str[pos_hifen + 1 ..], 10) catch return CronError.FormatoInvalido;
                if (inicio < min_val or fim > max_val or inicio > fim) return CronError.ValorForaDoRange;
                base = .{ .range = .{ .inicio = inicio, .fim = fim } };
            } else {
                return CronError.FormatoInvalido;
            }

            field.specs[field.count] = .{ .step = .{ .base = base, .passo = passo } };
        } else if (mem.eql(u8, parte, "*")) {
            field.specs[field.count] = .qualquer;
        } else if (mem.indexOf(u8, parte, "-")) |pos_hifen| {
            const inicio = fmt.parseInt(u8, parte[0..pos_hifen], 10) catch return CronError.FormatoInvalido;
            const fim = fmt.parseInt(u8, parte[pos_hifen + 1 ..], 10) catch return CronError.FormatoInvalido;
            if (inicio < min_val or fim > max_val or inicio > fim) return CronError.ValorForaDoRange;
            field.specs[field.count] = .{ .range = .{ .inicio = inicio, .fim = fim } };
        } else {
            const valor = fmt.parseInt(u8, parte, 10) catch return CronError.FormatoInvalido;
            if (valor < min_val or valor > max_val) return CronError.ValorForaDoRange;
            field.specs[field.count] = .{ .exato = valor };
        }

        field.count += 1;
    }

    if (field.count == 0) return CronError.CampoInvalido;
    return field;
}

/// Parseia uma expressão cron completa (5 campos separados por espaço).
fn parsearCron(expressao: []const u8) CronError!CronExpression {
    var result: CronExpression = undefined;
    @memcpy(result.raw[0..expressao.len], expressao);
    result.raw_len = expressao.len;

    var it = mem.splitScalar(u8, expressao, ' ');
    var campos: [5][]const u8 = undefined;
    var count: usize = 0;

    while (it.next()) |campo| {
        const trimmed = mem.trim(u8, campo, " \t");
        if (trimmed.len == 0) continue;
        if (count >= 5) return CronError.ExpressaoIncompleta;
        campos[count] = trimmed;
        count += 1;
    }

    if (count != 5) return CronError.ExpressaoIncompleta;

    result.minuto = try parsearCampo(campos[0], 0, 59);
    result.hora = try parsearCampo(campos[1], 0, 23);
    result.dia_mes = try parsearCampo(campos[2], 1, 31);
    result.mes = try parsearCampo(campos[3], 1, 12);
    result.dia_semana = try parsearCampo(campos[4], 0, 6);

    return result;
}

Passo 4: Descrição Legível e Próximas Execuções

/// Gera uma descrição legível em português da expressão cron.
fn descreverCron(expr: *const CronExpression, buf: []u8) ![]const u8 {
    var fbs = io.fixedBufferStream(buf);
    const writer = fbs.writer();

    try writer.writeAll("Executa ");

    // Minuto
    if (expr.minuto.count == 1 and expr.minuto.specs[0] == .qualquer) {
        try writer.writeAll("a cada minuto");
    } else if (expr.minuto.count == 1) {
        switch (expr.minuto.specs[0]) {
            .exato => |v| try writer.print("no minuto {d}", .{v}),
            .step => |s| try writer.print("a cada {d} minutos", .{s.passo}),
            else => try writer.writeAll("nos minutos especificados"),
        }
    }

    // Hora
    if (expr.hora.count == 1 and expr.hora.specs[0] == .qualquer) {
        try writer.writeAll(", toda hora");
    } else if (expr.hora.count == 1) {
        switch (expr.hora.specs[0]) {
            .exato => |v| try writer.print(", as {d}h", .{v}),
            .step => |s| try writer.print(", a cada {d} horas", .{s.passo}),
            .range => |r| try writer.print(", entre {d}h e {d}h", .{ r.inicio, r.fim }),
            else => {},
        }
    }

    // Dia do mês
    if (expr.dia_mes.count == 1 and expr.dia_mes.specs[0] != .qualquer) {
        switch (expr.dia_mes.specs[0]) {
            .exato => |v| try writer.print(", no dia {d}", .{v}),
            else => {},
        }
    }

    // Dia da semana
    const dias = [_][]const u8{ "dom", "seg", "ter", "qua", "qui", "sex", "sab" };
    if (expr.dia_semana.count == 1 and expr.dia_semana.specs[0] != .qualquer) {
        switch (expr.dia_semana.specs[0]) {
            .exato => |v| {
                if (v < 7) try writer.print(", {s}", .{dias[v]});
            },
            .range => |r| {
                if (r.inicio < 7 and r.fim < 7) {
                    try writer.print(", de {s} a {s}", .{ dias[r.inicio], dias[r.fim] });
                }
            },
            else => {},
        }
    }

    return fbs.getWritten();
}

/// Estrutura simples de data/hora para cálculos.
const DateTime = struct {
    ano: u16,
    mes: u8,
    dia: u8,
    hora: u8,
    minuto: u8,
    dia_semana: u8, // 0=domingo

    /// Avança um minuto.
    pub fn avancarMinuto(self: *DateTime) void {
        self.minuto += 1;
        if (self.minuto >= 60) {
            self.minuto = 0;
            self.hora += 1;
            if (self.hora >= 24) {
                self.hora = 0;
                self.dia += 1;
                self.dia_semana = (self.dia_semana + 1) % 7;
                const max_dia = diasNoMes(self.mes, self.ano);
                if (self.dia > max_dia) {
                    self.dia = 1;
                    self.mes += 1;
                    if (self.mes > 12) {
                        self.mes = 1;
                        self.ano += 1;
                    }
                }
            }
        }
    }

    pub fn format(self: DateTime, buf: []u8) ![]const u8 {
        return fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}", .{
            self.ano, self.mes, self.dia, self.hora, self.minuto,
        });
    }
};

fn diasNoMes(mes: u8, ano: u16) u8 {
    const dias_por_mes = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
    if (mes < 1 or mes > 12) return 30;
    if (mes == 2 and (ano % 4 == 0 and (ano % 100 != 0 or ano % 400 == 0))) return 29;
    return dias_por_mes[mes - 1];
}

/// Calcula as próximas N execuções de uma expressão cron.
fn proximasExecucoes(
    expr: *const CronExpression,
    inicio: DateTime,
    n: usize,
    resultados: []DateTime,
) usize {
    var dt = inicio;
    dt.avancarMinuto(); // Começar no próximo minuto

    var encontradas: usize = 0;
    var iteracoes: u32 = 0;
    const max_iteracoes: u32 = 525600 * 2; // 2 anos em minutos

    while (encontradas < n and iteracoes < max_iteracoes) : (iteracoes += 1) {
        if (expr.minuto.corresponde(dt.minuto) and
            expr.hora.corresponde(dt.hora) and
            expr.dia_mes.corresponde(dt.dia) and
            expr.mes.corresponde(dt.mes) and
            expr.dia_semana.corresponde(dt.dia_semana))
        {
            resultados[encontradas] = dt;
            encontradas += 1;
        }
        dt.avancarMinuto();
    }

    return encontradas;
}

Passo 5: Função Main

pub fn main() !void {
    const stdout = io.getStdOut().writer();
    const stdin = io.getStdIn().reader();

    try stdout.print(
        \\
        \\  ==========================================
        \\     CRON PARSER - Zig
        \\  ==========================================
        \\  Formato: minuto hora dia mes dia_semana
        \\
        \\  Exemplos:
        \\    */15 * * * *     - A cada 15 minutos
        \\    0 9 * * 1-5      - 9h em dias uteis
        \\    30 2 1 * *       - 2:30 no dia 1
        \\    0 */2 * * *      - A cada 2 horas
        \\
        \\  Digite 'sair' para encerrar.
        \\  ==========================================
        \\
        \\
    , .{});

    var buf: [256]u8 = undefined;

    while (true) {
        try stdout.print("cron> ", .{});

        const linha = stdin.readUntilDelimiter(&buf, '\n') catch |err| {
            if (err == error.EndOfStream) break;
            return err;
        };
        const trimmed = mem.trim(u8, linha, " \t\r\n");
        if (trimmed.len == 0) continue;
        if (mem.eql(u8, trimmed, "sair")) break;

        const expr = parsearCron(trimmed) catch |err| {
            try stdout.print("  Erro: {}\n", .{err});
            try stdout.print("  Formato: minuto hora dia mes dia_semana\n\n", .{});
            continue;
        };

        // Descrição legível
        var desc_buf: [512]u8 = undefined;
        const descricao = descreverCron(&expr, &desc_buf) catch "...";
        try stdout.print("\n  Expressao: {s}\n", .{expr.raw[0..expr.raw_len]});
        try stdout.print("  Descricao: {s}\n\n", .{descricao});

        // Próximas 5 execuções
        const agora = DateTime{
            .ano = 2026,
            .mes = 2,
            .dia = 21,
            .hora = 12,
            .minuto = 0,
            .dia_semana = 6, // sábado
        };

        var proximas: [5]DateTime = undefined;
        const n = proximasExecucoes(&expr, agora, 5, &proximas);

        try stdout.print("  Proximas {d} execucoes (a partir de 2026-02-21 12:00):\n", .{n});
        for (proximas[0..n]) |dt| {
            var dt_buf: [20]u8 = undefined;
            const formatada = dt.format(&dt_buf) catch "???";
            try stdout.print("    {s}\n", .{formatada});
        }
        try stdout.print("\n", .{});
    }
}

Testes

test "parsear wildcard" {
    const field = try parsearCampo("*", 0, 59);
    try std.testing.expect(field.corresponde(0));
    try std.testing.expect(field.corresponde(30));
    try std.testing.expect(field.corresponde(59));
}

test "parsear valor exato" {
    const field = try parsearCampo("15", 0, 59);
    try std.testing.expect(field.corresponde(15));
    try std.testing.expect(!field.corresponde(14));
}

test "parsear range" {
    const field = try parsearCampo("1-5", 0, 6);
    try std.testing.expect(field.corresponde(1));
    try std.testing.expect(field.corresponde(3));
    try std.testing.expect(field.corresponde(5));
    try std.testing.expect(!field.corresponde(0));
    try std.testing.expect(!field.corresponde(6));
}

test "parsear step" {
    const field = try parsearCampo("*/15", 0, 59);
    try std.testing.expect(field.corresponde(0));
    try std.testing.expect(field.corresponde(15));
    try std.testing.expect(field.corresponde(30));
    try std.testing.expect(!field.corresponde(7));
}

test "parsear lista" {
    const field = try parsearCampo("1,3,5", 0, 6);
    try std.testing.expect(field.corresponde(1));
    try std.testing.expect(field.corresponde(3));
    try std.testing.expect(field.corresponde(5));
    try std.testing.expect(!field.corresponde(2));
}

test "expressao cron completa" {
    const expr = try parsearCron("0 9 * * 1-5");
    try std.testing.expect(expr.minuto.corresponde(0));
    try std.testing.expect(expr.hora.corresponde(9));
    try std.testing.expect(!expr.hora.corresponde(10));
}

test "valor fora do range" {
    const result = parsearCampo("60", 0, 59);
    try std.testing.expectError(CronError.ValorForaDoRange, result);
}

test "dias no mes" {
    try std.testing.expectEqual(@as(u8, 28), diasNoMes(2, 2025));
    try std.testing.expectEqual(@as(u8, 29), diasNoMes(2, 2024));
    try std.testing.expectEqual(@as(u8, 31), diasNoMes(1, 2025));
}

Compilando e Executando

# Compilar e executar
zig build run

# Sessão de exemplo:
# cron> */15 * * * *
#   Expressao: */15 * * * *
#   Descricao: Executa a cada 15 minutos, toda hora
#   Proximas 5 execucoes:
#     2026-02-21 12:15
#     2026-02-21 12:30
#     2026-02-21 12:45
#     2026-02-21 13:00
#     2026-02-21 13:15

# Rodar testes
zig build test

Conceitos Aprendidos

  • Union types para representar variantes de especificação
  • Parsing multi-nível (vírgulas, barras, hífens)
  • Aritmética de data/hora com meses variáveis e anos bissextos
  • Correspondência de padrões com switch em unions
  • Buffers fixos para geração de strings sem alocação

Próximos Passos

Continue aprendendo Zig

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