// lesson: csi-parser

Parsing the Protocol β€” a VT State Machine

So far you've been speaking the protocol. A terminal emulator's defining job is the opposite: listening. Everything the programs on the pty slave print β€” shells, compilers, vim itself β€” arrives at your master fd as one undifferentiated byte stream, escape sequences mixed into text, and you must decide, byte by byte: is this a character to draw, or part of a command?

Why this must be a state machine

The tempting approach β€” "when I see ESC, read ahead until the sequence looks complete" β€” collapses on contact with reality, for one deep reason: the stream has no message boundaries. read() hands you whatever chunk happened to be in the kernel buffer. A single ESC[38;5;208m may arrive as ESC[3 in one read and 8;5;208m in the next; or a thousand sequences may arrive glued together in one 64 KiB read. Your parser can never assume "the rest is here" and can never afford to block waiting for it β€” there may be a screenful of printable text after the split point.

The clean solution is a pushdown-free state machine: an object that eats exactly one byte at a time, remembers where it is between bytes, and emits an event whenever a unit completes:

struct vt_parser p;
vt_parser_init(&p);

struct vt_event ev;
for (each byte b of whatever read() returned)
    if (vt_feed(&p, b, &ev))
        handle(&ev);       /* print a char, run a command, ... */

Because all context lives in the struct, chunk boundaries simply don't matter: feed it ESC [ 3 and it sits in the CSI state holding a half-built parameter; feed 8 ; 5 ... tomorrow and it carries on. This is the same architecture as every production emulator; xterm, st, and Alacritty differ in table encoding, not in shape. (The canonical description, reverse-engineered from real DEC hardware, is Paul Flo Williams' state diagram at vt100.net β€” linked in extended reading. Ours is a faithful subset.)

The states

For the VT100 core plus OSC-skipping, five states suffice:

GROUND ──ESC──► ESC_SEEN ──'['──► CSI ──final byte──► GROUND (emit CSI event)
   β”‚                β”‚
   β”‚                β”œβ”€']'──► OSC ──BEL or ESC \──► GROUND (no event)
   β”‚                β”‚
   β”‚                β”œβ”€'(' or ')'──► CHARSET ──any byte──► GROUND (ignore)
   β”‚                β”‚
   β”‚                └─other──► GROUND (emit simple-ESC event)
   β”‚
   β”œβ”€β”€ printable byte ──► GROUND (emit PRINT event)
   └── C0 control    ──► GROUND (emit CTRL event)

State by state:

  • GROUND β€” the resting state. Bytes β‰₯ 0x20 (and, for now, bytes β‰₯ 0x80 β€” that's UTF-8, next lesson's problem) are PRINT events, with one carve-out: 0x7F (DEL) is neither printable nor a C0 control β€” real terminals just swallow it, so the parser absorbs it silently and emits nothing. Bytes below 0x20 are C0 controls: ESC (0x1B) changes state; the rest (\n, \r, \b, \t, BEL…) are CTRL events for the screen layer to interpret.
  • ESC_SEEN β€” one byte of lookahead decides everything: [ opens a control sequence, ] opens an OSC string, ( / ) are the old charset-designation sequences (consume one more byte, ignore β€” you still see ESC ( B in the wild), anything else is a complete two-byte command (ESC 7, ESC 8, ESC c) β€” emit it.
  • CSI β€” the parameter grinder, detailed below.
  • OSC β€” swallow bytes (a window title, say) until the terminator: BEL, or ESC \ (which costs a tiny sub-state: an ESC inside OSC might be the start of the terminator). Emit nothing. Skipping correctly matters: mishandle one OSC and every byte after it is interpreted in the wrong state β€” the classic "my terminal went insane" failure.
  • CHARSET β€” consume exactly one byte, return to GROUND.

Grinding CSI parameters

Inside CSI, each byte is one of four things:

  • 0–9 β€” a digit of the current parameter: cur = cur * 10 + (b - '0').
  • ; β€” parameter separator: append cur to the list, reset the accumulator. An empty slot (ESC[;5H) appends 0 β€” by convention 0 and "absent" both mean "use the default", so storing 0 loses nothing.
  • ? (and its neighbors < = >, bytes 0x3C–0x3F) β€” mark the sequence private. Real parsers keep which byte; a flag is enough for us. Intermediates 0x20–0x2F may be silently ignored.
  • final byte 0x40–0x7E β€” flush the pending parameter (if the slot was started β€” digits seen or a ; consumed earlier), emit one CSI event carrying the final byte, the parameter list, and the private flag, and return to GROUND.

Two disciplines keep this robust against hostile streams (remember: cat /dev/urandom is a legal input!):

  • Bound the parameter list. ECMA-48 says implementations may limit parameters; 16 slots is generous. Extra parameters are parsed but dropped β€” never written past the array.
  • Bound the values. ESC[99999999999999H must not overflow int. Clamp each parameter at some sane ceiling (we use 65535) while accumulating.

Note what the parser does not do: it never interprets. It doesn't know that H moves cursors or that 2J clears screens β€” it only knows the grammar. Meaning is the screen layer's job, two lessons away. This separation is what makes both halves testable in isolation.

β€Ί A VT Parser

35 pts

Build it. The event and parser types are fixed by the starter; the behavior contract:

  • vt_parser_init(&p) β€” start in GROUND with an empty accumulator.
  • vt_feed(&p, byte, &ev) β€” consume one byte. Return 1 if ev was filled with a completed event, 0 if the byte was absorbed.

Events:

type fields used emitted for
VT_PRINT ch printable bytes β‰₯ 0x20 except 0x7F, and any byte β‰₯ 0x80, in GROUND
VT_CTRL ch C0 bytes in GROUND other than ESC (0x7F: absorb silently)
VT_CSI final, params[], nparams, priv completed control sequences
VT_ESC final two-byte ESC commands (ESC 7 β†’ final '7')

Parameter rules exactly as in the lesson: empty slots become 0; a sequence with no parameter bytes at all (ESC[H) has nparams == 0; at most 16 parameters, extras dropped; values clamped to 65535; bytes 0x3C–0x3F set priv; intermediates 0x20–0x2F ignored; OSC and charset sequences absorbed silently.

The tests feed sequences whole, split at every possible boundary, and interleaved with text β€” plus a light fuzz: 4 KiB of pseudo-random bytes must neither crash you nor leave you wedged (after feeding a BEL-terminated OSC and a fresh ESC[m, the parser must be back in business).

Log in to submit a solution and earn points.