⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

wc

Counting bytes is trivial and counting lines is nearly so: add one for every \n. Words are where it gets interesting, because a word is not a thing in the input. It is a pattern between things.

Try to count words directly and you immediately need rules for cases the input is full of. Two spaces between words. A leading space. A tab instead of a space. A line that ends without a newline. Handle each as a special case and you get a tangle that is wrong on the third one.

The trick is to stop counting words and count transitions. Keep one bit: are we inside a word right now? Every time that bit goes from false to true, a word has started. All the awkward cases stop being cases at all, because a run of twelve spaces sets the bit to false twelve times and false is already false.

That is a state machine, and it is the smallest useful one there is.

The program

const std = @import("std");

const Counts = struct { lines: usize = 0, words: usize = 0, bytes: usize = 0 };

fn isSpace(c: u8) bool {
    return c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == 11 or c == 12;
}

/// One pass, one byte at a time, one bit of state. `in_word` is the state
/// machine: a word is counted when the input crosses from space into non-space,
/// so runs of spaces cost nothing and the input is never looked at twice.
fn count(text: []const u8) Counts {
    var c: Counts = .{};
    var in_word = false;

    for (text) |ch| {
        c.bytes += 1;
        if (ch == '\n') c.lines += 1;

        if (isSpace(ch)) {
            in_word = false;
        } else if (!in_word) {
            in_word = true;
            c.words += 1;
        }
    }
    return c;
}

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout_writer.interface;

    const samples = [_][]const u8{
        "hello world\n",
        "  hello   world  \n",
        "one\ntwo\nthree\n",
        "no newline at the end",
        "",
    };

    for (samples) |text| {
        const c = count(text);
        try out.print("{d:>3} {d:>3} {d:>3}  ", .{ c.lines, c.words, c.bytes });
        try printEscaped(out, text);
        try out.writeAll("\n");
    }

    try out.flush();
}

/// Show the sample with its whitespace visible, so the counts can be checked.
fn printEscaped(out: *std.Io.Writer, text: []const u8) !void {
    try out.writeAll("\"");
    for (text) |ch| {
        if (ch == '\n') try out.writeAll("\\n") else try out.writeByte(ch);
    }
    try out.writeAll("\"");
}

What just happened

The samples are chosen so the counts prove the claim.

"hello world\n" and " hello world \n" both give 2 words. Different byte counts, 12 against 18, and the same word count. Nothing in the code special-cases leading or repeated whitespace; the transition never happens twice in a row because the bit is already set.

"no newline at the end" reports 0 lines and 5 words. This is not a bug, and it is worth sitting with. wc counts newline characters, so a file whose last line has no terminator has one fewer line than you would count by eye. Real wc does exactly this, and it is the reason a stray “0 lines” on a file you can plainly see text in means the file has no trailing newline.

The empty input gives three zeroes without any special handling, because a loop over nothing runs zero times.

One pass, one byte at a time. The counter never looks at a byte twice and never needs the whole input in memory, so this composes with the previous chapter: read a bufferful, feed it through the same state, repeat. Counting a file larger than memory needs no new ideas.

Check yourself

If you fed this program the six-byte string "héllo", how many words would it report?

One. The é is two bytes, neither of which is whitespace, so the bit is set once and stays set. Word counting is one of the few text operations that is genuinely correct on raw UTF-8 without knowing anything about it, because the only thing it asks of a byte is whether it is one of six ASCII whitespace values, and UTF-8 guarantees those bytes never appear inside a multi-byte character. Anything that needs character counts is a harder problem, which is the subject of Strings Are Bytes.

If you have written C

The C version is the same bit and the same loop:

int in_word = 0;
for (...) {
    if (isspace(c)) in_word = 0;
    else if (!in_word) { in_word = 1; words++; }
}

The one thing to be careful of in C is isspace(c) with a plain char. On a platform where char is signed, a byte above 127 becomes negative, and passing a negative value that is not EOF to isspace is undefined behaviour. The fix is isspace((unsigned char)c), and it is the kind of bug that survives for years because it only fires on non-ASCII input.

Zig’s u8 cannot be negative, so the question does not arise.

Next: grep, where the program has to answer a question rather than just report on what it saw.