Shell Unix Simples em Zig — Tutorial Passo a Passo

Shell Unix Simples em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um shell Unix funcional em Zig com suporte a execução de comandos, pipes, redirecionamento de I/O e comandos embutidos. Este projeto nos dá compreensão profunda de como sistemas operacionais gerenciam processos.

O Que Vamos Construir

Nosso shell vai:

  • Executar programas externos via fork + exec
  • Suportar pipes (cmd1 | cmd2 | cmd3)
  • Implementar redirecionamento (>, <, >>)
  • Incluir comandos embutidos: cd, pwd, echo, exit, env
  • Mostrar prompt com diretório atual
  • Tratar sinais (SIGINT para Ctrl+C)

Por Que Este Projeto?

O shell é a interface fundamental entre o usuário e o sistema operacional. Construir um nos ensina sobre fork/exec, file descriptors, pipes, sinais e gerenciamento de processos — conceitos centrais de sistemas Unix. Zig nos dá acesso direto às syscalls POSIX com segurança de tipos.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir shell-simples
cd shell-simples
zig init

Passo 2: Parser de Comandos

O parser precisa lidar com pipes, redirecionamento e argumentos entre aspas.

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

const MAX_ARGS = 64;
const MAX_PIPE = 16;

/// Tipo de redirecionamento.
const Redirecionar = enum {
    nenhum,
    saida,        // >
    saida_append, // >>
    entrada,      // <
};

/// Um comando simples (sem pipes).
const ComandoSimples = struct {
    args: [MAX_ARGS]?[*:0]const u8,
    argc: usize,
    redir_saida: ?[]const u8,
    redir_entrada: ?[]const u8,
    append: bool,

    pub fn init() ComandoSimples {
        return .{
            .args = [_]?[*:0]const u8{null} ** MAX_ARGS,
            .argc = 0,
            .redir_saida = null,
            .redir_entrada = null,
            .append = false,
        };
    }

    pub fn adicionarArg(self: *ComandoSimples, arg: [*:0]const u8) void {
        if (self.argc < MAX_ARGS - 1) {
            self.args[self.argc] = arg;
            self.argc += 1;
            self.args[self.argc] = null;
        }
    }
};

/// Uma pipeline: sequência de comandos conectados por pipes.
const Pipeline = struct {
    comandos: [MAX_PIPE]ComandoSimples,
    num_comandos: usize,

    pub fn init() Pipeline {
        var p = Pipeline{
            .comandos = undefined,
            .num_comandos = 0,
        };
        for (&p.comandos) |*c| {
            c.* = ComandoSimples.init();
        }
        return p;
    }
};

/// Faz o parsing de uma linha de comando em uma pipeline.
fn parsearLinha(
    linha: []const u8,
    tokens_buf: [][*:0]const u8,
    backing_buf: []u8,
) !Pipeline {
    var pipeline = Pipeline.init();
    var cmd_idx: usize = 0;
    var token_count: usize = 0;
    var backing_pos: usize = 0;

    var i: usize = 0;
    while (i < linha.len) {
        // Pular espaços
        while (i < linha.len and (linha[i] == ' ' or linha[i] == '\t')) : (i += 1) {}
        if (i >= linha.len) break;

        // Pipe
        if (linha[i] == '|') {
            cmd_idx += 1;
            if (cmd_idx >= MAX_PIPE) return error.MuitosPipes;
            i += 1;
            continue;
        }

        // Redirecionamento
        if (linha[i] == '>') {
            const append = (i + 1 < linha.len and linha[i + 1] == '>');
            i += if (append) @as(usize, 2) else @as(usize, 1);
            // Pular espaços
            while (i < linha.len and linha[i] == ' ') : (i += 1) {}
            // Ler nome do arquivo
            const inicio = i;
            while (i < linha.len and linha[i] != ' ' and linha[i] != '|') : (i += 1) {}
            pipeline.comandos[cmd_idx].redir_saida = linha[inicio..i];
            pipeline.comandos[cmd_idx].append = append;
            continue;
        }

        if (linha[i] == '<') {
            i += 1;
            while (i < linha.len and linha[i] == ' ') : (i += 1) {}
            const inicio = i;
            while (i < linha.len and linha[i] != ' ' and linha[i] != '|') : (i += 1) {}
            pipeline.comandos[cmd_idx].redir_entrada = linha[inicio..i];
            continue;
        }

        // Token normal
        const inicio = i;
        while (i < linha.len and linha[i] != ' ' and linha[i] != '\t' and
            linha[i] != '|' and linha[i] != '>' and linha[i] != '<') : (i += 1)
        {}
        const token = linha[inicio..i];

        // Converter para null-terminated
        if (backing_pos + token.len + 1 <= backing_buf.len and token_count < tokens_buf.len) {
            @memcpy(backing_buf[backing_pos .. backing_pos + token.len], token);
            backing_buf[backing_pos + token.len] = 0;
            const ptr: [*:0]const u8 = @ptrCast(backing_buf[backing_pos .. backing_pos + token.len + 1]);
            tokens_buf[token_count] = ptr;
            pipeline.comandos[cmd_idx].adicionarArg(ptr);
            token_count += 1;
            backing_pos += token.len + 1;
        }
    }

    pipeline.num_comandos = cmd_idx + 1;
    return pipeline;
}

Passo 3: Comandos Embutidos

/// Verifica se é um comando embutido e executa.
/// Retorna true se foi tratado como embutido.
fn executarEmbutido(cmd: *const ComandoSimples, stdout: anytype) !bool {
    if (cmd.argc == 0) return true;

    const nome = mem.span(cmd.args[0].?);

    if (mem.eql(u8, nome, "cd")) {
        const dir = if (cmd.argc > 1) mem.span(cmd.args[1].?) else "/";
        posix.chdir(dir) catch |err| {
            try stdout.print("cd: {}: {}\n", .{ dir, err });
        };
        return true;
    }

    if (mem.eql(u8, nome, "pwd")) {
        var buf: [4096]u8 = undefined;
        const cwd = posix.getcwd(&buf) catch "???";
        try stdout.print("{s}\n", .{cwd});
        return true;
    }

    if (mem.eql(u8, nome, "echo")) {
        for (1..cmd.argc) |i| {
            if (i > 1) try stdout.print(" ", .{});
            try stdout.print("{s}", .{mem.span(cmd.args[i].?)});
        }
        try stdout.print("\n", .{});
        return true;
    }

    if (mem.eql(u8, nome, "exit") or mem.eql(u8, nome, "sair")) {
        posix.exit(0);
    }

    if (mem.eql(u8, nome, "env")) {
        const env = std.process.environ();
        for (env) |entry| {
            try stdout.print("{s}\n", .{mem.span(entry)});
        }
        return true;
    }

    if (mem.eql(u8, nome, "help") or mem.eql(u8, nome, "ajuda")) {
        try stdout.print(
            \\  Comandos embutidos:
            \\    cd <dir>     - Mudar diretorio
            \\    pwd          - Mostrar diretorio atual
            \\    echo <args>  - Imprimir texto
            \\    env          - Listar variaveis de ambiente
            \\    exit/sair    - Sair do shell
            \\    ajuda        - Esta mensagem
            \\
        , .{});
        return true;
    }

    return false;
}

Passo 4: Execução de Processos

/// Executa um comando simples (sem pipe) em um processo filho.
fn executarComando(cmd: *const ComandoSimples) !void {
    if (cmd.argc == 0) return;

    // Redirecionamento de entrada
    if (cmd.redir_entrada) |arquivo| {
        const path_z = try toNullTerminated(arquivo);
        const fd = try posix.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
        try posix.dup2(fd, posix.STDIN_FILENO);
        posix.close(fd);
    }

    // Redirecionamento de saída
    if (cmd.redir_saida) |arquivo| {
        const path_z = try toNullTerminated(arquivo);
        const flags: posix.O = .{
            .ACCMODE = .WRONLY,
            .CREAT = true,
            .APPEND = cmd.append,
            .TRUNC = !cmd.append,
        };
        const fd = try posix.open(path_z, flags, 0o644);
        try posix.dup2(fd, posix.STDOUT_FILENO);
        posix.close(fd);
    }
}

fn toNullTerminated(s: []const u8) ![*:0]const u8 {
    // Para simplificar, retornamos o pointer se o slice já termina com 0
    if (s.len > 0 and s.ptr[s.len] == 0) {
        return @ptrCast(s.ptr);
    }
    // Caso contrário, precisaríamos alocar — simplificação para o tutorial
    return @ptrCast(s.ptr);
}

/// Executa uma pipeline completa com pipes entre processos.
fn executarPipeline(pipeline: *const Pipeline) !void {
    if (pipeline.num_comandos == 1) {
        // Comando simples sem pipe
        const cmd = &pipeline.comandos[0];
        const pid = try posix.fork();

        if (pid == 0) {
            // Processo filho
            executarComando(cmd) catch posix.exit(1);

            const argv = @as([*:null]const ?[*:0]const u8, @ptrCast(&cmd.args));
            const err = posix.execvpeZ(cmd.args[0].?, argv, std.process.environ().ptr);
            _ = err;
            posix.exit(127);
        } else {
            // Processo pai — esperar o filho
            _ = posix.waitpid(pid, 0);
        }
        return;
    }

    // Pipeline com múltiplos comandos
    var pipes: [MAX_PIPE - 1][2]posix.fd_t = undefined;

    // Criar os pipes necessários
    for (0..pipeline.num_comandos - 1) |i| {
        pipes[i] = try posix.pipe();
    }

    // Fork cada comando
    for (0..pipeline.num_comandos) |i| {
        const cmd = &pipeline.comandos[i];
        const pid = try posix.fork();

        if (pid == 0) {
            // Processo filho

            // Conectar stdin ao pipe anterior
            if (i > 0) {
                try posix.dup2(pipes[i - 1][0], posix.STDIN_FILENO);
            }

            // Conectar stdout ao próximo pipe
            if (i < pipeline.num_comandos - 1) {
                try posix.dup2(pipes[i][1], posix.STDOUT_FILENO);
            }

            // Fechar todos os pipes no filho
            for (0..pipeline.num_comandos - 1) |j| {
                posix.close(pipes[j][0]);
                posix.close(pipes[j][1]);
            }

            executarComando(cmd) catch posix.exit(1);

            const argv = @as([*:null]const ?[*:0]const u8, @ptrCast(&cmd.args));
            const err = posix.execvpeZ(cmd.args[0].?, argv, std.process.environ().ptr);
            _ = err;
            posix.exit(127);
        }
    }

    // Pai: fechar todos os pipes
    for (0..pipeline.num_comandos - 1) |i| {
        posix.close(pipes[i][0]);
        posix.close(pipes[i][1]);
    }

    // Esperar todos os filhos
    for (0..pipeline.num_comandos) |_| {
        _ = posix.waitpid(-1, 0);
    }
}

Passo 5: Loop Principal do Shell

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

    try stdout.print(
        \\
        \\  ==========================================
        \\     SHELL SIMPLES - Zig
        \\  ==========================================
        \\  Suporte: pipes (|), redirecionar (> >> <)
        \\  Embutidos: cd, pwd, echo, env, exit, ajuda
        \\  ==========================================
        \\
        \\
    , .{});

    var buf: [4096]u8 = undefined;
    var tokens_buf: [MAX_ARGS * MAX_PIPE][*:0]const u8 = undefined;
    var backing_buf: [8192]u8 = undefined;

    while (true) {
        // Prompt com diretório atual
        var cwd_buf: [256]u8 = undefined;
        const cwd = posix.getcwd(&cwd_buf) catch "?";

        // Extrair só o último componente do path
        const dir_nome = if (mem.lastIndexOfScalar(u8, cwd, '/')) |pos|
            cwd[pos + 1 ..]
        else
            cwd;

        try stdout.print("zigsh:{s}$ ", .{if (dir_nome.len == 0) "/" else dir_nome});

        const linha = stdin.readUntilDelimiter(&buf, '\n') catch |err| {
            if (err == error.EndOfStream) {
                try stdout.print("\n", .{});
                break;
            }
            return err;
        };

        const trimmed = mem.trim(u8, linha, " \t\r\n");
        if (trimmed.len == 0) continue;

        // Parsear a linha
        var pipeline = parsearLinha(trimmed, &tokens_buf, &backing_buf) catch |err| {
            try stdout.print("Erro de parsing: {}\n", .{err});
            continue;
        };

        // Verificar se é comando embutido (apenas para pipeline de 1 comando)
        if (pipeline.num_comandos == 1) {
            if (try executarEmbutido(&pipeline.comandos[0], stdout)) {
                continue;
            }
        }

        // Executar pipeline
        executarPipeline(&pipeline) catch |err| {
            try stdout.print("Erro: {}\n", .{err});
        };
    }
}

Testes

test "parser - comando simples" {
    var tokens_buf: [64][*:0]const u8 = undefined;
    var backing_buf: [1024]u8 = undefined;
    const pipeline = try parsearLinha("ls -la", &tokens_buf, &backing_buf);
    try std.testing.expectEqual(@as(usize, 1), pipeline.num_comandos);
    try std.testing.expectEqual(@as(usize, 2), pipeline.comandos[0].argc);
}

test "parser - pipe simples" {
    var tokens_buf: [64][*:0]const u8 = undefined;
    var backing_buf: [1024]u8 = undefined;
    const pipeline = try parsearLinha("ls | grep txt", &tokens_buf, &backing_buf);
    try std.testing.expectEqual(@as(usize, 2), pipeline.num_comandos);
}

test "parser - redirecionamento" {
    var tokens_buf: [64][*:0]const u8 = undefined;
    var backing_buf: [1024]u8 = undefined;
    const pipeline = try parsearLinha("echo hello > out.txt", &tokens_buf, &backing_buf);
    try std.testing.expectEqual(@as(usize, 1), pipeline.num_comandos);
    try std.testing.expect(pipeline.comandos[0].redir_saida != null);
}

Compilando e Executando

zig build run

# Exemplos de uso:
# zigsh:~$ ls -la
# zigsh:~$ echo hello world
# zigsh:~$ ls | grep zig
# zigsh:~$ cat arquivo.txt | sort | uniq > resultado.txt
# zigsh:~$ pwd
# zigsh:~$ cd /tmp
# zigsh:~$ sair

Conceitos Aprendidos

  • fork() e exec() para criação de processos
  • Pipes Unix para comunicação inter-processos
  • Redirecionamento de I/O com dup2()
  • File descriptors e gerenciamento de recursos
  • Parsing de linha de comando com tokens

Próximos Passos

Continue aprendendo Zig

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