File Watcher em Zig — Tutorial Passo a Passo

File Watcher em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um monitor de sistema de arquivos que detecta alterações (criação, modificação, exclusão) em diretórios monitorados e executa ações configuráveis. Em Linux, usamos a API inotify; como fallback, usamos polling com verificação de timestamps.

O Que Vamos Construir

Nosso file watcher vai:

  • Monitorar um ou mais diretórios por alterações
  • Detectar criação, modificação e exclusão de arquivos
  • Filtrar por extensão de arquivo (.zig, .txt, etc.)
  • Executar comandos customizados quando mudanças são detectadas
  • Suportar debounce para evitar execuções duplicadas
  • Usar inotify no Linux para eficiência

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir file-watcher
cd file-watcher
zig init

Passo 2: Tipos de Evento

const std = @import("std");
const fs = std.fs;
const mem = std.mem;
const posix = std.posix;
const Allocator = std.mem.Allocator;

/// Tipo de evento no sistema de arquivos.
const TipoEvento = enum {
    criado,
    modificado,
    removido,
    renomeado,

    pub fn nome(self: TipoEvento) []const u8 {
        return switch (self) {
            .criado => "CRIADO",
            .modificado => "MODIFICADO",
            .removido => "REMOVIDO",
            .renomeado => "RENOMEADO",
        };
    }

    pub fn cor(self: TipoEvento) []const u8 {
        return switch (self) {
            .criado => "\x1b[32m",
            .modificado => "\x1b[33m",
            .removido => "\x1b[31m",
            .renomeado => "\x1b[36m",
        };
    }
};

/// Um evento detectado no sistema de arquivos.
const EventoArquivo = struct {
    tipo: TipoEvento,
    caminho: [256]u8,
    caminho_len: usize,
    timestamp: i64,

    pub fn caminhoStr(self: *const EventoArquivo) []const u8 {
        return self.caminho[0..self.caminho_len];
    }
};

/// Configuração do watcher.
const ConfigWatcher = struct {
    extensoes: [8][]const u8 = undefined,
    num_extensoes: usize = 0,
    debounce_ms: u64 = 500,
    recursivo: bool = false,
    comando: ?[]const u8 = null,

    pub fn aceitaExtensao(self: *const ConfigWatcher, nome_arquivo: []const u8) bool {
        if (self.num_extensoes == 0) return true; // aceita tudo
        for (self.extensoes[0..self.num_extensoes]) |ext| {
            if (mem.endsWith(u8, nome_arquivo, ext)) return true;
        }
        return false;
    }
};

Passo 3: Watcher com Polling

O modo polling funciona em qualquer sistema operacional, verificando timestamps periodicamente.

/// Estado de um arquivo monitorado.
const EstadoArquivo = struct {
    nome: [256]u8,
    nome_len: usize,
    tamanho: u64,
    mtime: i128,
    existe: bool,

    pub fn nomeStr(self: *const EstadoArquivo) []const u8 {
        return self.nome[0..self.nome_len];
    }
};

/// Watcher baseado em polling (verificação periódica).
const PollingWatcher = struct {
    diretorio: []const u8,
    estados: [512]EstadoArquivo,
    num_estados: usize,
    config: ConfigWatcher,

    const Self = @This();

    pub fn init(diretorio: []const u8, config: ConfigWatcher) Self {
        return .{
            .diretorio = diretorio,
            .estados = undefined,
            .num_estados = 0,
            .config = config,
        };
    }

    /// Escaneia o diretório e atualiza o estado interno.
    /// Retorna os eventos detectados.
    pub fn verificar(self: *Self, eventos: []EventoArquivo) !usize {
        var num_eventos: usize = 0;

        // Escaneia diretório atual
        var dir = fs.cwd().openDir(self.diretorio, .{ .iterate = true }) catch return 0;
        defer dir.close();

        // Marca todos como possivelmente removidos
        var i: usize = 0;
        while (i < self.num_estados) : (i += 1) {
            self.estados[i].existe = false;
        }

        var iter = dir.iterate();
        while (try iter.next()) |entry| {
            if (entry.kind != .file) continue;
            if (!self.config.aceitaExtensao(entry.name)) continue;

            // Busca estado anterior
            const stat = dir.statFile(entry.name) catch continue;
            const idx = self.encontrarEstado(entry.name);

            if (idx) |existing| {
                self.estados[existing].existe = true;

                // Verificar modificação
                if (stat.mtime != self.estados[existing].mtime or
                    stat.size != self.estados[existing].tamanho)
                {
                    self.estados[existing].mtime = stat.mtime;
                    self.estados[existing].tamanho = stat.size;

                    if (num_eventos < eventos.len) {
                        var ev = &eventos[num_eventos];
                        ev.tipo = .modificado;
                        const len = @min(entry.name.len, ev.caminho.len);
                        @memcpy(ev.caminho[0..len], entry.name[0..len]);
                        ev.caminho_len = len;
                        ev.timestamp = std.time.timestamp();
                        num_eventos += 1;
                    }
                }
            } else {
                // Novo arquivo
                if (self.num_estados < self.estados.len) {
                    var estado = &self.estados[self.num_estados];
                    const nlen = @min(entry.name.len, estado.nome.len);
                    @memcpy(estado.nome[0..nlen], entry.name[0..nlen]);
                    estado.nome_len = nlen;
                    estado.tamanho = stat.size;
                    estado.mtime = stat.mtime;
                    estado.existe = true;
                    self.num_estados += 1;

                    if (num_eventos < eventos.len) {
                        var ev = &eventos[num_eventos];
                        ev.tipo = .criado;
                        @memcpy(ev.caminho[0..nlen], entry.name[0..nlen]);
                        ev.caminho_len = nlen;
                        ev.timestamp = std.time.timestamp();
                        num_eventos += 1;
                    }
                }
            }
        }

        // Detectar removidos
        i = 0;
        while (i < self.num_estados) {
            if (!self.estados[i].existe) {
                if (num_eventos < eventos.len) {
                    var ev = &eventos[num_eventos];
                    ev.tipo = .removido;
                    const nlen = self.estados[i].nome_len;
                    @memcpy(ev.caminho[0..nlen], self.estados[i].nome[0..nlen]);
                    ev.caminho_len = nlen;
                    ev.timestamp = std.time.timestamp();
                    num_eventos += 1;
                }
                // Remover do array (swap com último)
                self.estados[i] = self.estados[self.num_estados - 1];
                self.num_estados -= 1;
            } else {
                i += 1;
            }
        }

        return num_eventos;
    }

    fn encontrarEstado(self: *Self, nome: []const u8) ?usize {
        for (self.estados[0..self.num_estados], 0..) |*estado, idx| {
            if (mem.eql(u8, estado.nomeStr(), nome)) return idx;
        }
        return null;
    }
};

Passo 4: Loop Principal

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

    try stdout.print(
        \\
        \\  ==========================================
        \\     FILE WATCHER - Zig
        \\  ==========================================
        \\
    , .{});

    var buf: [256]u8 = undefined;

    try stdout.print("  Diretorio para monitorar (. = atual): ", .{});
    const dir_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch "." orelse ".";
    const diretorio = mem.trim(u8, dir_raw, " \t\r\n");

    var config = ConfigWatcher{};

    try stdout.print("  Extensoes para filtrar (ex: .zig,.txt ou vazio para todas): ", .{});
    const ext_raw = stdin.readUntilDelimiterOrEof(&buf, '\n') catch "" orelse "";
    const ext_str = mem.trim(u8, ext_raw, " \t\r\n");

    if (ext_str.len > 0) {
        var ext_iter = mem.splitScalar(u8, ext_str, ',');
        while (ext_iter.next()) |ext| {
            if (config.num_extensoes < 8) {
                config.extensoes[config.num_extensoes] = mem.trim(u8, ext, " ");
                config.num_extensoes += 1;
            }
        }
    }

    try stdout.print(
        \\
        \\  Monitorando: {s}
        \\  Filtros: {d} extensoes
        \\  Intervalo: {d}ms
        \\
        \\  Pressione Ctrl+C para parar...
        \\
    , .{ diretorio, config.num_extensoes, config.debounce_ms });

    var watcher = PollingWatcher.init(diretorio, config);

    // Scan inicial (silencioso)
    var eventos_iniciais: [64]EventoArquivo = undefined;
    _ = try watcher.verificar(&eventos_iniciais);

    // Loop de monitoramento
    while (true) {
        std.time.sleep(config.debounce_ms * std.time.ns_per_ms);

        var eventos: [64]EventoArquivo = undefined;
        const num = try watcher.verificar(&eventos);

        for (eventos[0..num]) |*ev| {
            const reset = "\x1b[0m";
            try stdout.print("  {s}[{s}]{s} {s}\n", .{
                ev.tipo.cor(), ev.tipo.nome(), reset, ev.caminhoStr(),
            });
        }
    }
}

Testes

test "config aceita extensao" {
    var config = ConfigWatcher{};
    config.extensoes[0] = ".zig";
    config.extensoes[1] = ".txt";
    config.num_extensoes = 2;

    try std.testing.expect(config.aceitaExtensao("main.zig"));
    try std.testing.expect(config.aceitaExtensao("readme.txt"));
    try std.testing.expect(!config.aceitaExtensao("image.png"));
}

test "config sem filtro aceita tudo" {
    const config = ConfigWatcher{};
    try std.testing.expect(config.aceitaExtensao("qualquer.xyz"));
}

test "tipo evento nome" {
    try std.testing.expectEqualStrings("CRIADO", TipoEvento.criado.nome());
    try std.testing.expectEqualStrings("REMOVIDO", TipoEvento.removido.nome());
}

Compilando e Executando

zig build test
zig build run

Conceitos Aprendidos

  • Iteração de diretórios com std.fs
  • Comparação de timestamps para detecção de mudanças
  • Polling como estratégia de monitoramento portável
  • Filtragem por extensão de arquivo
  • Arrays fixos para evitar alocação dinâmica
  • Loop de monitoramento com std.time.sleep

Próximos Passos

Continue aprendendo Zig

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