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
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com strings e parsing
- Conhecimento básico de structs
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
- Explore parsing de strings para formatos complexos
- Veja o Task Scheduler que usa cron expressions
- Construa o INI Parser para outro formato de parsing
- Consulte union types na documentação