# Writing a Small Shell

### Writing a Small Shell

A shell is a program that reads commands and runs other programs.

When you type this:

```bash
ls
```

the shell starts the `ls` program.

When you type this:

```bash
echo hello
```

the shell starts the `echo` program and passes `hello` as an argument.

A small shell has this shape:

```text
print prompt
read one line
split line into arguments
run the command
repeat
```

#### The Simplest Shell Loop

A shell is usually a loop.

```zig
const std = @import("std");

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

    var buffer: [1024]u8 = undefined;

    while (true) {
        try stdout.writeAll("zsh> ");

        const line_or_null = try stdin.readUntilDelimiterOrEof(&buffer, '\n');
        const line = line_or_null orelse break;

        const clean_line = std.mem.trim(u8, line, " \t\r\n");

        if (clean_line.len == 0) {
            continue;
        }

        if (std.mem.eql(u8, clean_line, "exit")) {
            break;
        }

        try stdout.print("command: {s}\n", .{clean_line});
    }
}
```

This does not run commands yet. It only reads them.

Still, it already has the basic shell structure:

```text
prompt
read
parse
handle
repeat
```

#### Splitting a Command Line

A command like this:

```bash
echo hello zig
```

has three words:

```text
echo
hello
zig
```

The first word is the program name. The rest are arguments.

For a first shell, split on spaces and tabs.

```zig
fn splitLine(
    allocator: std.mem.Allocator,
    line: []const u8,
) ![][]const u8 {
    var args = std.ArrayList([]const u8).init(allocator);
    errdefer args.deinit();

    var it = std.mem.tokenizeAny(u8, line, " \t");

    while (it.next()) |part| {
        try args.append(part);
    }

    return try args.toOwnedSlice();
}
```

This parser is intentionally simple. It does not support quotes, escapes, variables, pipes, or redirection.

So this works:

```bash
echo hello zig
```

But this does not work correctly yet:

```bash
echo "hello zig"
```

A real shell grammar is much harder. Start with words.

#### Running a Program

Zig can start a child process with `std.process.Child`.

```zig
fn runCommand(allocator: std.mem.Allocator, args: []const []const u8) !void {
    if (args.len == 0) {
        return;
    }

    var child = std.process.Child.init(args, allocator);

    const result = try child.spawnAndWait();

    switch (result) {
        .Exited => |code| {
            if (code != 0) {
                std.debug.print("exit code: {}\n", .{code});
            }
        },
        else => {
            std.debug.print("process ended: {}\n", .{result});
        },
    }
}
```

The argument list is passed directly to the child process.

For:

```bash
echo hello zig
```

the list is:

```text
args[0] = echo
args[1] = hello
args[2] = zig
```

The operating system runs `echo` with those arguments.

#### A Working Tiny Shell

Now combine the pieces.

```zig
const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const stdin = std.io.getStdIn().reader();
    const stdout = std.io.getStdOut().writer();

    var buffer: [1024]u8 = undefined;

    while (true) {
        try stdout.writeAll("zsh> ");

        const line_or_null = try stdin.readUntilDelimiterOrEof(&buffer, '\n');
        const line = line_or_null orelse break;

        const clean_line = std.mem.trim(u8, line, " \t\r\n");

        if (clean_line.len == 0) {
            continue;
        }

        if (std.mem.eql(u8, clean_line, "exit")) {
            break;
        }

        const args = try splitLine(allocator, clean_line);
        defer allocator.free(args);

        try runCommand(allocator, args);
    }
}

fn splitLine(
    allocator: std.mem.Allocator,
    line: []const u8,
) ![][]const u8 {
    var args = std.ArrayList([]const u8).init(allocator);
    errdefer args.deinit();

    var it = std.mem.tokenizeAny(u8, line, " \t");

    while (it.next()) |part| {
        try args.append(part);
    }

    return try args.toOwnedSlice();
}

fn runCommand(allocator: std.mem.Allocator, args: []const []const u8) !void {
    if (args.len == 0) {
        return;
    }

    var child = std.process.Child.init(args, allocator);

    const result = try child.spawnAndWait();

    switch (result) {
        .Exited => |code| {
            if (code != 0) {
                std.debug.print("exit code: {}\n", .{code});
            }
        },
        else => {
            std.debug.print("process ended: {}\n", .{result});
        },
    }
}
```

Build it:

```bash
zig build-exe shell.zig
```

Run it:

```bash
./shell
```

Try:

```bash
echo hello
```

Try:

```bash
zig version
```

Try:

```bash
exit
```

#### Built-In Commands

Some commands must be handled by the shell itself.

For example, `exit` cannot be an ordinary child process if it is supposed to stop the shell.

Another important built-in is `cd`.

If you run `cd` as a child process, only the child changes directory. Then the child exits. The shell stays in the same directory.

So `cd` must change the shell process itself.

```zig
fn handleBuiltin(args: []const []const u8) !bool {
    if (args.len == 0) {
        return true;
    }

    if (std.mem.eql(u8, args[0], "exit")) {
        return false;
    }

    if (std.mem.eql(u8, args[0], "cd")) {
        if (args.len < 2) {
            std.debug.print("cd: missing path\n", .{});
            return true;
        }

        std.posix.chdir(args[1]) catch |err| {
            std.debug.print("cd: {}\n", .{err});
        };

        return true;
    }

    return true;
}
```

This function returns `false` when the shell should stop.

#### Adding Built-Ins to the Loop

Use the built-in handler before running a child process.

```zig
const keep_going = try handleBuiltin(args);

if (!keep_going) {
    break;
}

if (isBuiltin(args[0])) {
    continue;
}

try runCommand(allocator, args);
```

A cleaner design separates detection from execution.

```zig
fn isBuiltin(name: []const u8) bool {
    return std.mem.eql(u8, name, "exit") or
        std.mem.eql(u8, name, "cd");
}
```

Now `exit` and `cd` are handled by the shell. Everything else is launched as a child process.

#### Environment and PATH

When you type:

```bash
ls
```

you usually do not type the full path:

```bash
/bin/ls
```

The shell searches directories listed in the `PATH` environment variable.

A typical `PATH` looks like this:

```text
/usr/local/bin:/usr/bin:/bin
```

The shell tries:

```text
/usr/local/bin/ls
/usr/bin/ls
/bin/ls
```

until it finds an executable.

`std.process.Child` can use the operating system's normal process lookup behavior depending on how it is configured and the platform. A complete shell should understand `PATH`, absolute paths, relative paths, and platform differences.

For a beginner shell, let `std.process.Child` handle the basic case and focus on the command loop.

#### Standard Input and Output

By default, a child process usually inherits the shell's stdin, stdout, and stderr.

That is why this works naturally:

```bash
echo hello
```

The child writes to the same terminal as the shell.

Later, for redirection, the shell must change where the child reads or writes.

Example shell syntax:

```bash
echo hello > out.txt
```

This means:

```text
run echo
send stdout to out.txt
```

That requires opening `out.txt`, then giving the child process that file as stdout.

#### Pipes

A pipe connects the output of one process to the input of another.

```bash
cat file.txt | grep error
```

Conceptually:

```text
cat stdout -> pipe -> grep stdin
```

This is one of the core shell features.

A tiny first shell does not need pipes. But the mental model is important: the shell creates processes and wires their file descriptors together.

#### Quoting Is Hard

This command looks simple:

```bash
echo "hello world"
```

But the shell must keep `hello world` as one argument.

Without quote handling, a simple space splitter produces:

```text
echo
"hello
world"
```

That is wrong.

A real shell must handle:

quotes

backslashes

environment variables

command substitution

glob patterns

comments

operators like `|`, `>`, `<`, `&&`, `||`

This is why a real shell has a parser, not just a tokenizer.

For now, keep the grammar small.

#### Error Handling

A shell should not crash because one command fails.

If a child process exits with code `1`, the shell should continue.

If the command does not exist, print an error and continue.

```zig
runCommand(allocator, args) catch |err| {
    std.debug.print("{s}: {}\n", .{ args[0], err });
};
```

This keeps the shell alive.

The shell is the supervisor. Commands may fail. The shell keeps reading.

#### Resource Cleanup

A shell repeatedly allocates and starts processes. Cleanup matters.

In the tiny shell:

```zig
const args = try splitLine(allocator, clean_line);
defer allocator.free(args);
```

The argument slice is freed after each command.

For child processes, `spawnAndWait` starts the process and waits for it to finish. A more advanced shell that starts background jobs must track child processes carefully.

#### What This Shell Cannot Do Yet

This small shell can:

read a command

split it into words

run a program

handle `exit`

possibly handle `cd`

It cannot properly handle:

quoted strings

pipes

redirection

background jobs

signals

history

tab completion

environment variable expansion

wildcards

job control

That is fine. The goal is to understand the base mechanism.

#### Mental Model

A shell is a command loop around process creation.

It reads a line from the terminal, parses it into a command, handles built-ins itself, and asks the operating system to run external programs.

The hard part of a real shell is not only starting processes. The hard part is parsing, redirection, pipes, signals, job control, and preserving the behavior users expect.

