Lesson 5.1

Building the CLI

The user-facing tool that ties lexer, parser, runtime, and codegen into one command.

build go 15 min read

The front door to the compiler.

You have a lexer that turns source into tokens. A parser that turns tokens into an AST. A code generator that turns the AST into C. A runtime that makes the C work. Four separate machines, each doing one job.

The CLI is the hallway that connects all four rooms. The user walks in the front door, says what they want (build, run, check), and the CLI routes them through the right sequence of rooms. Without the CLI, the compiler is four libraries with no way to call them.

This is the simplest phase in the entire project. No new algorithms, no data structures, no language theory. Just plumbing -- reading arguments, calling functions, printing results. But it's the phase where the compiler becomes a tool.

Go makes CLIs trivial.

Some languages need a CLI framework. Go doesn't. The standard library gives you everything: os.Args for raw arguments, flag for parsing flags, fmt for output, os for exit codes. No third-party dependencies.

The pattern is simple: read os.Args, match the first argument against your known commands, dispatch. A switch statement is your router. For flags like -o, Go's flag package handles parsing and validation.

Monk's entire CLI is about 255 lines of Go. One file. One main function. No frameworks, no config files, no plugins.

Six commands, six jobs.

Every compiler needs these verbs. Here's what each one does:

monk build hello.monk

Compile a Monk source file to a native binary. This is the full pipeline: lex, parse, generate C, invoke the system C compiler, produce an executable. The output file name matches the input (hello.monk becomes hello).

monk build -o hello.c hello.monk

Emit C source instead of a binary. The -o flag controls the output path, and the file extension decides the format. If it ends in .c, you get C source. Anything else, you get a binary. No separate --emit-c flag needed.

monk run hello.monk

Compile and run in one step. Internally it runs the full build pipeline to a temp binary, executes it, then deletes the temp file. Syntactic sugar for development -- you don't want to name a binary every time you test a change.

monk check hello.monk

Parse and validate without compiling. Runs the lexer and parser only -- no codegen, no C compilation. Fast enough for editor integration and CI checks. If the syntax is valid, it prints nothing and exits 0.

monk version

Print the version string. Semantic versioning: monk version X.Y.Z.

monk help

Print usage information. Lists all commands with one-line descriptions.

Each command uses a different slice of the pipeline.

Not every command needs every stage. The CLI decides how far down the pipeline to go:

Command
Lex
Parse
Codegen
C Compile
Run
check
yes
yes
--
--
--
build -o x.c
yes
yes
yes
--
--
build
yes
yes
yes
yes
--
run
yes
yes
yes
yes
yes

This is why the compiler is built as separate packages. The CLI imports the scanner, parser, and codegen, then chains them together as needed. Each command is just a different stopping point.

The -o flag: one flag, two formats.

Most compilers have separate flags for "emit intermediate representation" vs "produce a binary." Monk uses a simpler approach: the -o flag sets the output path, and the file extension decides the format.

# Produce a native binary
monk build -o hello hello.monk

# Emit C source code
monk build -o hello.c hello.monk

If -o ends with .c, write C source. Otherwise, compile all the way to a binary. One flag, zero ambiguity. The convention is self-documenting -- you can see what format you're getting from the filename alone.

Errors that tell you where to look.

When the compiler finds a problem, it reports file, line, and column:

hello.monk:3:12: error: undefined variable "x"
hello.monk:7:1: error: expected "end" to close function

This format is deliberate. Most editors can parse file:line:column: message and jump directly to the error. GCC, Clang, and Go all use this format. Following the convention means free editor integration.

Exit codes follow Unix convention: 0 for success, 1 for compile errors (your code has a problem), 2 for usage errors (you called the CLI wrong). Scripts and CI pipelines rely on these.

Testing a CLI means testing the whole compiler.

CLI tests are integration tests by nature. They exercise the full pipeline end-to-end. The strategy:

1

Create a temp .monk file with known source code.

2

Run the CLI command (build, run, check) against that file.

3

Check stdout, stderr, and the exit code.

4

For build: verify the output file exists and is executable. For build -o x.c: verify the output contains valid C.

5

Clean up temp files.

28 tests cover every command, every flag, every error path. Each test is self-contained -- creates its own files, runs the command, checks the result, cleans up. No test depends on another.

The CLI is about 255 lines. No framework, no config files, no plugins. One Go file, one switch statement, done. Keep it simple until you need more.

Key takeaways

1

The CLI is the user's interface to the compiler. It routes commands to the right pipeline stages.

2

Go's standard library is all you need. os.Args, flag, and a switch statement.

3

The -o flag's file extension decides the output format. .c = C source, anything else = binary.

4

Error format follows file:line:column: message -- the universal convention that editors can parse.

5

CLI tests are integration tests. They write .monk files, run the compiler, and check everything end-to-end.