04 — First Projects

When: W2, W3, W4 — one project per week, roughly 4–6 evening hours each. Prereq: Drills 1–24 (roughly). The projects assume you can build with CMake and step in lldb.

Drills teach you syntax. Projects teach you what happens when syntax meets a spec that doesn’t care how you feel. Each project below has hard acceptance criteria — not “it kind of works,” but byte-for-byte matching a reference tool. That is deliberate. study partners and production users don’t grade on effort.


Ground rules for all three projects

  • One project = one CMake project = one git repo (local is fine; you don’t need to push).

  • Directory layout:

    project_name/
      CMakeLists.txt
      .clangd
      src/
        main.cpp
        ... (split when file exceeds ~250 lines)
      include/
        ... (headers when you extract a class)
      tests/
        test_*.cpp
      fixtures/
        ... (sample input files)
      README.md
    
  • Every project’s CMakeLists.txt sets: C++20, -Wall -Wextra -Wpedantic -Werror, CMAKE_EXPORT_COMPILE_COMMANDS ON.

  • Every project ends with the acceptance test running from a shell script run_acceptance.sh — not “I tried a few examples.”

  • Every project has a README.md in its folder explaining: what it does, how to build, how to run, known limitations. 30–50 lines.


What most people get wrong

They start with the fun part (the algorithm, the parser) and skip the boring part (the harness, the tests). Then when it doesn’t quite match wc, they spend two hours guessing instead of writing a diff loop. Write the acceptance test first — the moment you can run ./run_acceptance.sh and see it fail, you have a definition of done. Without it, you’ll ship “close enough” three times in a month and learn nothing.


P0.1 — CLI Word Counter (Week 2)

Goal: Rebuild your comfort with file I/O, string iteration, and byte-level correctness. Reference tool: BSD wc (the one shipped with macOS, at /usr/bin/wc). Time budget: ~5 hours across 3–4 evenings.

Spec

Write a binary wcpp that behaves like wc for a single-file argument:

Usage: wcpp <file>
Output format (exactly, including whitespace):
     <lines>    <words>   <bytes> <file>
  • Lines = number of \n characters in the file. A trailing line without a newline does not count as a line. Match wc’s convention exactly.

  • Words = maximal runs of non-whitespace characters separated by whitespace (isspace(): space, tab, newline, vertical tab, form feed, carriage return).

  • Bytes = total bytes read.

  • Field widths: right-aligned in an 8-character field (matches BSD wc).

Acceptance criteria

Create fixtures/ with 5 files:

  1. empty.txt — zero bytes.

  2. one_line.txt — one line with a trailing newline: "hello world\n".

  3. no_trailing_newline.txt — same content, no trailing \n.

  4. mixed_whitespace.txt — lines with tabs and multiple spaces between words.

  5. large.txt — the text of a public-domain book (drop in alice.txt from Project Gutenberg or generate ~1 MB of lorem ipsum).

Write run_acceptance.sh:

#!/usr/bin/env bash
set -e
for f in fixtures/*.txt; do
  expected=$(/usr/bin/wc "$f")
  actual=$(./build/wcpp "$f")
  if [ "$expected" != "$actual" ]; then
    echo "FAIL: $f"
    echo "  expected: $expected"
    echo "  actual:   $actual"
    exit 1
  fi
  echo "PASS: $f"
done

Definition of done: All 5 files print PASS. No exceptions to “byte-for-byte match.”

Common bugs you will hit

  • Off-by-one on lines with no trailing newline. Reading the file with std::getline and counting iterations gets this wrong — getline still returns for a trailing line without \n. Fix: count actual \n bytes.

  • Word count mismatches on Unicode. BSD wc counts bytes, not code points, in its default mode. If your file has UTF-8 multi-byte chars, wc -c and wc -m differ. Match wc -c behavior (bytes).

  • Buffering the entire file in memory for large.txt. Stream it. Use a 64 KB read buffer with std::ifstream::read or read byte-by-byte with std::istreambuf_iterator<char>.

  • Field-width formatting off by one space. BSD wc’s output isn’t tab-separated; it’s space-padded with an 8-char field. Look at wc /etc/passwd output first; count the spaces.

Extension challenges (only after acceptance passes)

  • Support multiple files with a total line: wcpp a.txt b.txt.

  • Add --utf8 flag that counts code points, matching wc -m.

  • Benchmark against /usr/bin/wc on a 100 MB file. Your version should be within 2x.


P0.2 — Tiny JSON Pretty-Printer (Week 3)

Goal: Character-by-character parsing, small state machines, understanding tokens vs syntax. Reference tool: python -m json.tool (installed with macOS Python 3). Time budget: ~6 hours across 3–4 evenings.

Spec

Write a binary jsonpp that reads a JSON document from stdin and writes a pretty-printed version to stdout with 2-space indentation:

cat input.json | ./jsonpp > output.json
diff output.json <(cat input.json | python -m json.tool --indent=2)

Scope of JSON supported (flat is fine):

  • Objects: { "key": value, ... }

  • Arrays: [ value, value, ... ]

  • Strings: "..." with basic escapes \", \\, \n, \t, \r

  • Numbers: integers and decimals (positive/negative, no scientific notation required)

  • Booleans: true, false

  • Null: null

  • Nesting: at least 4 levels deep

Not required: unicode \uXXXX escapes, scientific-notation numbers, JSON5 extensions, comments.

Architecture guidance (do not skip this)

Before writing any code, sketch on paper (real paper):

  1. Tokenizer: input stream → sequence of tokens {LBRACE, RBRACE, LBRACKET, RBRACKET, COLON, COMMA, STRING, NUMBER, TRUE, FALSE, NULL}.

  2. Parser: token stream → tree of Value (a std::variant<std::monostate, bool, double, std::string, Array, Object> — recursive types need std::unique_ptr or a trick like std::vector<Value>).

  3. Printer: tree → stdout with correct indentation.

Do not conflate parsing and printing into one pass. Separate them. When P0.2 is done, the tokenizer and parser are reusable for later projects; a fused parse-and-print is not.

Acceptance criteria

Write fixtures/ with 3 input files:

  1. flat.json{"name": "Raghul", "age": 25, "active": true, "city": null}.

  2. nested.json — an object with an array of objects, 3 levels deep.

  3. edge_cases.json — empty object {}, empty array [], string with escaped quote and backslash, negative number.

Write run_acceptance.sh:

#!/usr/bin/env bash
set -e
for f in fixtures/*.json; do
  expected=$(python3 -m json.tool --indent=2 < "$f")
  actual=$(./build/jsonpp < "$f")
  if [ "$expected" != "$actual" ]; then
    echo "FAIL: $f"
    diff <(echo "$expected") <(echo "$actual") | head -20
    exit 1
  fi
  echo "PASS: $f"
done

Definition of done: All 3 files match Python’s output byte-for-byte, ignoring only a final trailing newline.

Common bugs you will hit

  • Number formatting. Python prints 1.0 for 1.0 and 1 for 1. You must preserve the source form (or decide on a rule and match Python’s). Easiest fix: hold numbers as std::string in the AST rather than parsing to double.

  • Comma placement. Every value in an object/array is followed by , except the last one. Off-by-one here is the #1 cause of diff mismatches.

  • Empty containers. {} and [] render on one line, not with a blank interior. Python’s json.tool handles this; make sure you do too.

  • String escaping. When printing, you must re-escape \n as the two chars \ and n, not as an actual newline. Same for \", \\, \t, \r.

  • Recursive types. std::variant<..., Object> where Object contains more Values won’t compile because the type is infinite in size. Solutions: std::unique_ptr<Object>, or Object = std::vector<std::pair<std::string, Value>> with a forward declaration.

Extension challenges

  • Add --sort-keys flag that sorts object keys alphabetically.

  • Support scientific notation in numbers.

  • Add a --minify mode (opposite of pretty-print).

  • Benchmark against python3 -m json.tool on a 10 MB JSON file. You should be at least 5x faster.


P0.3 — Log Tail Filter (Week 4)

Goal: Real-time file I/O, signal handling, std::regex, clean shutdown. Reference tool: tail -f logfile | grep -E pattern. Time budget: ~5 hours across 3 evenings.

Spec

Write a binary logtail that behaves like tail -f with an optional regex filter:

Usage: logtail [--filter REGEX] <path>
  • Open the file, seek to end (like tail -f without -n and without -c), then poll for new content.

  • Print each new line to stdout as it arrives.

  • If --filter REGEX is provided, only print lines that match (using std::regex_search, ECMAScript syntax).

  • Handle SIGINT (Ctrl-C) cleanly: print \n[logtail: shutting down]\n to stderr, close the file, exit 0.

  • Handle file truncation (log rotation): if the file shrinks between polls, seek back to end 0 and continue.

  • Handle file deletion: print an error to stderr and exit 1.

Implementation guidance

  • Polling: loop with std::this_thread::sleep_for(std::chrono::milliseconds(100)) between reads. Do not busy-spin.

  • Signal handling: std::signal(SIGINT, handler) where handler sets an std::atomic<bool> keep_running{false}. Main loop checks it every iteration.

  • Track file position with std::ifstream::tellg(). After each read, remember where you stopped.

  • Detect truncation: std::filesystem::file_size() compared against your last known position. If size < position, the file was truncated.

  • Compile the regex once, outside the loop (see drill 30 for the reason).

Acceptance criteria

Write run_acceptance.sh that does this real-time test:

#!/usr/bin/env bash
set -e
tmp=$(mktemp)
./build/logtail --filter "ERROR|WARN" "$tmp" > out.txt 2> err.txt &
pid=$!
sleep 0.5
echo "INFO: normal message" >> "$tmp"
echo "ERROR: something broke" >> "$tmp"
echo "DEBUG: irrelevant" >> "$tmp"
echo "WARN: heads up" >> "$tmp"
sleep 0.5
kill -INT $pid
wait $pid || true
expected_lines=2
actual_lines=$(wc -l < out.txt | tr -d ' ')
if [ "$actual_lines" != "$expected_lines" ]; then
  echo "FAIL: expected $expected_lines matching lines, got $actual_lines"
  cat out.txt
  exit 1
fi
if ! grep -q "shutting down" err.txt; then
  echo "FAIL: did not shut down cleanly"
  cat err.txt
  exit 1
fi
rm -f "$tmp" out.txt err.txt
echo "PASS"

Definition of done:

  • Two matching lines in out.txt (ERROR and WARN), not more, not fewer.

  • "shutting down" appears in stderr.

  • No dangling file descriptors (verify manually with lsof -p $pid before the kill during a longer test).

Common bugs you will hit

  • std::signal handler calling anything unsafe. Inside a signal handler, you can safely touch only std::atomic variables and volatile sig_atomic_t. Do not call std::cout, do not allocate. Just flip the atomic.

  • ifstream::eof() sticking. Once eof is set, subsequent reads fail even if new data arrives. Fix: file.clear() before each read attempt.

  • Missing lines on rapid writes. If your poll interval is 100 ms and the writer flushes 500 lines in one burst, you’ll read them all together — that’s fine. Just make sure your read loop keeps reading until readsome() returns zero.

  • Regex compiled every iteration. Compile once as a const std::regex outside the loop, or you’ll be dozens of times slower.

  • Not resetting position after truncation. After truncation, tellg() returns your old position, but the file is now shorter. Read attempts will silently fail. Explicitly file.seekg(0, std::ios::end).

Extension challenges

  • Add -n N to print the last N lines before following (like tail -n 20 -f).

  • Add --invert to print lines that do NOT match the regex (like grep -v).

  • Support multiple files with a prefix: ==> path <== before each file’s lines (like tail -f a.log b.log).

  • Replace polling with kqueue (BSD) for zero-latency event-driven updates. Note: platform-specific; a good stretch.


Retrospective at end of W4

Sit down for 30 minutes on Sunday of W4 and write in RETRO.md:

  1. Which project took longer than expected, and why.

  2. Which bug from the “common bugs” lists actually bit you.

  3. One thing you’ll do differently in Phase 1.

  4. Rate your comfort 1–10 with: file I/O, string manipulation, std::regex, signal handling, CMake configuration, lldb debugging.

Anything below 6 becomes a warm-up exercise in the first week of Phase 1.


Return to Phase 0 README · Next: 05_common_pitfalls_returning_dev.md