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.txtsets: 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.mdin 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.shand 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
\ncharacters in the file. A trailing line without a newline does not count as a line. Matchwc’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:
empty.txt— zero bytes.one_line.txt— one line with a trailing newline:"hello world\n".no_trailing_newline.txt— same content, no trailing\n.mixed_whitespace.txt— lines with tabs and multiple spaces between words.large.txt— the text of a public-domain book (drop inalice.txtfrom Project Gutenberg or generate ~1 MB oflorem 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::getlineand counting iterations gets this wrong —getlinestill returns for a trailing line without\n. Fix: count actual\nbytes.Word count mismatches on Unicode. BSD
wccounts bytes, not code points, in its default mode. If your file has UTF-8 multi-byte chars,wc -candwc -mdiffer. Matchwc -cbehavior (bytes).Buffering the entire file in memory for
large.txt. Stream it. Use a 64 KB read buffer withstd::ifstream::reador read byte-by-byte withstd::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 atwc /etc/passwdoutput first; count the spaces.
Extension challenges (only after acceptance passes)¶
Support multiple files with a total line:
wcpp a.txt b.txt.Add
--utf8flag that counts code points, matchingwc -m.Benchmark against
/usr/bin/wcon 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,\rNumbers: integers and decimals (positive/negative, no scientific notation required)
Booleans:
true,falseNull:
nullNesting: 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):
Tokenizer: input stream → sequence of tokens
{LBRACE, RBRACE, LBRACKET, RBRACKET, COLON, COMMA, STRING, NUMBER, TRUE, FALSE, NULL}.Parser: token stream → tree of
Value(astd::variant<std::monostate, bool, double, std::string, Array, Object>— recursive types needstd::unique_ptror a trick likestd::vector<Value>).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:
flat.json—{"name": "Raghul", "age": 25, "active": true, "city": null}.nested.json— an object with an array of objects, 3 levels deep.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.0for1.0and1for1. You must preserve the source form (or decide on a rule and match Python’s). Easiest fix: hold numbers asstd::stringin the AST rather than parsing todouble.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’sjson.toolhandles this; make sure you do too.String escaping. When printing, you must re-escape
\nas the two chars\andn, not as an actual newline. Same for\",\\,\t,\r.Recursive types.
std::variant<..., Object>whereObjectcontains moreValues won’t compile because the type is infinite in size. Solutions:std::unique_ptr<Object>, orObject = std::vector<std::pair<std::string, Value>>with a forward declaration.
Extension challenges¶
Add
--sort-keysflag that sorts object keys alphabetically.Support scientific notation in numbers.
Add a
--minifymode (opposite of pretty-print).Benchmark against
python3 -m json.toolon 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 -fwithout-nand without-c), then poll for new content.Print each new line to stdout as it arrives.
If
--filter REGEXis provided, only print lines that match (usingstd::regex_search, ECMAScript syntax).Handle SIGINT (Ctrl-C) cleanly: print
\n[logtail: shutting down]\nto 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)wherehandlersets anstd::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(ERRORandWARN), not more, not fewer."shutting down"appears in stderr.No dangling file descriptors (verify manually with
lsof -p $pidbefore the kill during a longer test).
Common bugs you will hit¶
std::signalhandler calling anything unsafe. Inside a signal handler, you can safely touch onlystd::atomicvariables and volatile sig_atomic_t. Do not callstd::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::regexoutside 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. Explicitlyfile.seekg(0, std::ios::end).
Extension challenges¶
Add
-n Nto print the last N lines before following (liketail -n 20 -f).Add
--invertto print lines that do NOT match the regex (likegrep -v).Support multiple files with a prefix:
==> path <==before each file’s lines (liketail -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:
Which project took longer than expected, and why.
Which bug from the “common bugs” lists actually bit you.
One thing you’ll do differently in Phase 1.
Rate your comfort 1–10 with: file I/O, string manipulation,
std::regex, signal handling, CMake configuration,lldbdebugging.
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