C / CAPSTONE PROJECTS
Project: a word-count clone that handles any input
Build a wc clone that counts lines, words and bytes in one pass over raw bytes, staying correct on NUL bytes, CRLF, no trailing newline and huge streams.
What you will learn
- Count words with one in_word flag: a word starts at each non-space that follows a space.
- Read input as unsigned char bytes so NUL and invalid UTF-8 pass through unchanged.
- Hold getchar()'s result in an int so EOF stays distinct from the byte 0xFF.
- Count lines by counting '\n', so a file with no final newline reports one line less.
Understanding Project: a word-count clone that handles any input
Strip wc down and it is a loop over bytes with three counters and one bit of state. Bytes is just the loop count; lines is the number of '\n' bytes seen, not the number of visible rows; words is the number of times the input goes from whitespace to non-whitespace. That last definition is the whole trick: instead of hunting for where a word ends, you count where a word starts, so you need no lookahead and never have to store the word itself.
"Any input" means bytes, not C strings. Real input arrives with NUL bytes in the middle, CR before LF, no newline at the end, invalid UTF-8, or as a 20 GB pipe that will never fit in memory, which disqualifies fgets, strlen and strtok outright since the first NUL byte would silently end your data. Read with getchar, getc or fread into unsigned char and interpret the bytes no further than the whitespace test. Also keep getchar's result in an int: it returns 0-255 or EOF, and squeezing that into a char means either a 0xFF byte compares equal to EOF and the count stops early, or char is unsigned and EOF can never match so the loop never ends.
The one real policy decision is what counts as whitespace and what counts as a character. In the C locale isspace is exactly space, tab, newline, vertical tab, form feed and carriage return, which is what the state machine below hardcodes; if you call isspace instead, write isspace((unsigned char)b), because passing a negative char is undefined behaviour. Separately, wc -c and wc -m answer different questions, bytes versus characters, and only the byte answer is independent of encoding and locale. Pick which one your clone reports and label it honestly, rather than printing a byte count under the heading "characters".
<stdio.h>
<stddef.h>
typedef struct { unsigned long long lines, words, bytes; } Counts;
static int wc_space(unsigned char b)
{
return b == ' ' || b == '\t' || b == '\n'
|| b == '\v' || b == '\f' || b == '\r';
}
static Counts count_bytes(const unsigned char *p, size_t n)
{
Counts c = {0, 0, 0};
int in_word = 0;
for (size_t i = 0; i < n; i++) {
unsigned char b = p[i];
c.bytes++;
if (b == '\n') c.lines++;
if (wc_space(b)) in_word = 0;
else if (!in_word) { in_word = 1; c.words++; }
}
return c;
}
int main(void)
{
static const unsigned char data[] =
"alpha beta\r\n"
"\tgamma\0delta \n"
"epsilon"; /* no trailing newline */
size_t n = sizeof data - 1; /* drop the literal's own terminator */
Counts c = count_bytes(data, n);
printf("lines=%llu words=%llu bytes=%llu\n", c.lines, c.words, c.bytes);
return 0;
}
wc is a single pass over raw bytes in which the only state you need is a flag saying whether you are currently inside a word.
Worked examples
Bytes versus characters on a UTF-8 stream
Counts a real file with getc and reports byte and character totals separately, the difference between wc -c and wc -m.
<stdio.h>
int main(void)
{
FILE *f = fopen("wc_demo.txt", "wb");
if (!f) return 1;
fputs("na\xc3\xaf" "ve caf\xc3\xa9" "\n", f); /* UTF-8: naive/cafe accented */
fclose(f);
f = fopen("wc_demo.txt", "rb");
if (!f) return 1;
unsigned long long lines = 0, words = 0, chars = 0, bytes = 0;
int in_word = 0, ch;
while ((ch = getc(f)) != EOF) {
unsigned char b = (unsigned char)ch;
bytes++;
if ((b & 0xC0) != 0x80) chars++; /* not a continuation byte */
if (b == '\n') lines++;
if (b == ' ' || b == '\t' || b == '\n' ||
b == '\v' || b == '\f' || b == '\r') in_word = 0;
else if (!in_word) { in_word = 1; words++; }
}
fclose(f);
remove("wc_demo.txt");
printf("lines=%llu words=%llu chars=%llu bytes=%llu\n",
lines, words, chars, bytes);
return 0;
}
Example explained
Line 1The literal is written in pieces because a hex escape swallows every hex digit that follows it; keeping "\xc3" separate guarantees one byte, not a giant escape.
Line 2(b & 0xC0) != 0x80 skips UTF-8 continuation bytes, which always look like 10xxxxxx, so chars counts code points while bytes counts octets.
Line 3ch is an int because getc yields 0-255 or EOF; the cast back to unsigned char gives the plain byte value used in the comparisons.
Line 4bytes=13 but chars=11 because the two accented letters each occupy two bytes.
Words that straddle a read boundary
Shows why the in_word flag must live outside the fread loop by using an absurdly small buffer.
<stdio.h>
<string.h>
static int wsp(unsigned char b)
{
return b == ' ' || b == '\t' || b == '\n'
|| b == '\v' || b == '\f' || b == '\r';
}
int main(void)
{
const char *text = "one two three four";
FILE *f = fopen("chunks.txt", "wb");
if (!f) return 1;
fwrite(text, 1, strlen(text), f);
fclose(f);
unsigned char buf[4]; /* tiny on purpose: words get split */
size_t got, words = 0;
int in_word = 0; /* stream state, not buffer state */
f = fopen("chunks.txt", "rb");
if (!f) return 1;
while ((got = fread(buf, 1, sizeof buf, f)) > 0) {
for (size_t i = 0; i < got; i++) {
if (wsp(buf[i])) in_word = 0;
else if (!in_word) { in_word = 1; words++; }
}
}
if (ferror(f)) fputs("read error\n", stderr);
fclose(f);
remove("chunks.txt");
printf("words=%zu (read %zu bytes at a time)\n", words, sizeof buf);
return 0;
}
Example explained
Line 1The 4-byte buffer splits the input as "one ", "two ", "thre", "e fo", "ur", so two of the four words cross a read boundary.
Line 2in_word is declared before the while loop, so the second half of "three" is seen as a continuation instead of a fresh word; declaring it inside the loop prints words=6.
Line 3fread signals end of input with a short or zero return, not EOF, which is why the loop condition tests > 0 and ferror is checked afterwards.
Line 4sizeof buf and words are size_t values, so they print with %zu rather than %d.
Important notes
Counters typed size_t wrap after 4 GiB on 32-bit builds; use unsigned long long or uintmax_t for a tool you point at arbitrary streams.
The continuation-byte trick for character counts assumes well-formed UTF-8; a general wc -m needs setlocale(LC_CTYPE, "") with mbrtowc and a stated policy for invalid sequences.
Common mistakes
Writing char c = getchar(): with signed char a 0xFF byte compares equal to EOF and the counts stop at that byte, and with unsigned char the EOF test never matches so the loop spins forever.
Using fgets plus strlen to measure each line: an embedded NUL byte truncates the line, so bytes and words come out low and your total never matches wc -c on binary input.
Declaring or resetting in_word inside the per-chunk loop: every word crossing a read boundary is counted twice, and the wrong total shifts whenever you change the buffer size.
Try it yourself
Change, predict, then run
Add a longest-line counter (wc -L) to the byte loop: track the current run length, reset it at each '\n', and keep the maximum. Confirm that input ending without a newline still reports the length of its last line.
Open the C workspaceCheck your understanding
An 11-byte file contains hello world with no newline at the end, and your counter only increments lines when it sees a '\n' byte. What should it print, and is that a bug?
- lines=1, words=2 — hitting EOF with a partial line has to count as a line
- lines=0, words=1 — the second word is dropped because nothing terminates it
- lines=0, words=2 — correct, since lines counts '\n' bytes and each word is counted where it starts
- Undefined — the state machine only settles if the input ends with a newline
Show answer
lines is defined as the number of '\n' bytes, so this file reports 0, exactly as wc -l does. Words are counted on the whitespace-to-non-whitespace transition, so both are already counted when they begin and no terminator is needed. Option 1 is tempting because an editor shows one line, but adding an EOF fixup would also make an empty file report lines=1, disagreeing with wc.