Building a command-line tool
This guide builds count, a small wc-like tool: arguments and --help, reading files,
error messages and exit codes, and tests. A Velt program is a single native executable with no
runtime to install and no warm-up, which suits command-line tools.
velt new tool --template cli generates a similar skeleton with subcommands and tests.
The program
velt new count
cd countThe work goes into a module, src/count.vlt: run takes the arguments and returns the lines
to print, so tests can call it without starting a process.
import { ArgParser, CliError } from "velt/cli";
import { readFileSync } from "velt/fs";
export class InputError extends Error {}
function parser(): ArgParser {
return new ArgParser("count", "Counts lines, words and bytes.")
.flag("lines", { short: "l", help: "Print only the line count" })
.option("top", { short: "t", default: "0", help: "Also print the n most common words", valueName: "n" })
.positional("file", "The file to read");
}
function read(file: string): string {
try {
return readFileSync(file);
} catch (e) { // e: IoError
throw new InputError(`cannot read ${file} (${e.code})`);
}
}
function words(text: string): string[] {
return text.replaceAll("\n", " ").split(" ").filter((w) => w !== "");
}
function topWords(text: string, n: i64): string[] {
const freq = new Map<string, i64>();
for (const w of words(text)) {
freq.upsert(w.toLowerCase(), 1, (c) => c + 1);
}
const entries = freq.entries();
entries.sort((a, b) => b[1] - a[1]);
return entries.slice(0, n).map((e) => `${e[1]} ${e[0]}`);
}
// Runs the command line `argv` and returns the lines to print.
export function run(argv: string[]): string[] {
const p = parser();
const opts = p.parse(argv);
if (opts.flag("help")) {
return [p.help()];
}
const file = opts.get("file");
if (file == null) {
throw new CliError("missing <file> (try --help)");
}
const text = read(file);
const lines = text.split("\n").length - 1;
if (opts.flag("lines")) {
return [`${lines}`];
}
const out = [`${lines} ${words(text).length} ${text.length} ${file}`];
return out.concat(topWords(text, opts.getInt("top") ?? 0));
}src/main.vlt is the process around it: arguments in, lines out, errors to stderr with an
exit code.
import { CliError } from "velt/cli";
import { args } from "velt/process";
import { run } from "./count";
try {
for (const line of run(args())) {
console.log(line);
}
} catch (e) { // e: CliError | InputError
console.error(`count: ${e.message}`);
process.exit(e instanceof CliError ? 2 : 1);
}$ velt build --release
$ cat poem.txt
the cat sat
on the mat
$ ./target/velt/count poem.txt --top 2
2 6 23 poem.txt
2 the
1 cat
$ ./target/velt/count --nope
count: Unknown option '--nope'
$ ./target/velt/count missing.txt
count: cannot read missing.txt (ENOENT)
$ ./target/velt/count --help
Usage: count [options] [file]
Counts lines, words and bytes.
Arguments:
file The file to read
Options:
-h, --help Show this help
-l, --lines Print only the line count
-t, --top <n> Also print the n most common words (default: 0)velt run -- poem.txt --top 2 builds and runs it in one step; the arguments follow --.
How it fits together
Arguments
velt/cli's ArgParser follows Node's util.parseArgs
rules (--x, --x=v, -abc, --no-x, --). Builder calls chain; parse throws a
CliError for an unknown option or a missing value, and help() renders the usage text.
parse takes the argument array, so the logic is testable without a process. args() from
velt/process is the program's arguments, process.argv.slice(2) in
Node's terms.
- Top-level code:
main.vlt's statements are the program, as in a Node script; there is nomainto write. A file withfunction main()and no top-level statements runsmaininstead, and amain(): i32returns the exit code (scripts). - Exit codes:
process.exit(code)ends the program at once with that code. Here usage errors exit with 2 and input errors with 1; a program that finishes normally exits with 0, and an uncaught error printsUncaught …and exits with 1. - Typed errors:
readconverts theIoErrorfromreadFileSync(whosecodeis a Node-style name such asENOENT) into anInputError, so thecatchsees exactlyCliError | InputErrorand can choose the exit code withinstanceof. - Output:
console.loggoes to stdout,console.errorto stderr, andprocess.stdout.write(s)writes without a newline. For large outputs, build the text first and print it once, or write in chunks withvelt/fs's streams. - Input:
readFileSyncreads a whole file;velt/process'sstdin,readLineandreadAllread standard input line by line or at once;openReadreads large files in chunks.
Testing
Because run takes the argument array and returns lines, tests call it directly. In
tests/count.test.vlt, with the poem in tests/data/poem.txt:
import { run } from "../src/count";
export function test_counts() {
assertEq(run(["tests/data/poem.txt", "-l"]), ["2"]);
}
export function test_usage_error() {
try {
run([]);
assert(false, "expected a CliError");
} catch (e) {
assertEq(e.message, "missing <file> (try --help)");
}
}$ velt test
running tests/count.test.vlt (ran: not cacheable, it uses the file system)
ok test_counts
ok test_usage_error
test result: ok. 2 passed; 0 failedThe tests import src/count.vlt, not src/main.vlt: importing a module runs its top-level
statements. velt test runs every exported test_* function (Testing).
Running other programs
velt/process/child runs other programs and captures their output:
import { execSync } from "velt/process/child";
const r = execSync("git", ["rev-parse", "--short", "HEAD"]);
console.log(r.ok ? r.stdout.trim() : `git failed (${r.code})`);Shipping it
velt build --release produces one executable, target/velt/count, with no runtime
dependency beyond the system C library. Build on the oldest Linux distribution you target
(glibc is linked dynamically). See Platforms.