FREAKV3 docs freak 0.15.1 (Maverick)

Tutorial 2: A command-line tool#

Read arguments, read a file, count what is in it, and print a coloured report that respects NO_COLOR. About 25 minutes.

By the end you will have a small wc-style tool. Along the way: the argument API, file I/O without result<T,E>, hand-rolled text scanning, ANSI colour, and exit codes.

Start from Tutorial 1 if you have not written any FREAK yet.

Step 1 — Arguments#

There is no process::args() returning a list. V3 rejects it deliberately rather than handing you the runtime's raw argv as a fake handle. Use the indexed pair instead:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task main() -> void {
    say word_from_int(process::args_count())

    pilot mut i: int = 0
    repeat process::args_count() times {
        say "arg {i}: " + process::arg(i)
        i += 1
    }
}

Argument 0 is the executable path, so the first real argument is index 1.

That loop is a counted repeat plus a manual index. repeat N times with i supplies the index for you, starting at 0.

Step 2 — Reading a file#

fs::* are builtins. There is no result<T,E> anywhere in V3, so failures come back as sentinel values — an empty word, or false:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task main() -> void {
    pilot path: word = "notes.txt"

    if not fs::exists(path) {
        say "no such file: " + path
        process::exit(1)
    }

    pilot text: word = fs::read(path)
    say word_from_int(text.length())
}

process::exit(code) ends the program immediately. Check fs::exists before reading — an unreadable file and an empty file both come back as "".

Note

fs::delete returns true when the file is gone, whether this call removed it or it was already absent. It only returns false when the path could not be unlinked.

Step 3 — Making the tool self-contained#

So it always has something to work on, write a sample when no argument is given:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task sample_path() -> word {
    pilot path: word = "sample.txt"
    fs::write(path, "the beta do not negotiate\nhumanity answers with steel\n")
    give back path
}

task target_path() -> word {
    if process::args_count() > 1 {
        give back process::arg(1)
    }
    give back sample_path()
}

Step 4 — Counting#

No regex, no .split() into a list. Walk the characters:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task count_words(text: word) -> int {
    pilot mut total: int = 0
    pilot mut in_word: bool = false
    pilot mut i: int = 0

    repeat text.length() times {
        pilot c: word = text.char_at(i)
        pilot blank: bool = c == " " or c == "\n" or c == "\t" or c == "\r"
        if blank {
            in_word = false
        } else {
            if not in_word { total += 1 }
            in_word = true
        }
        i += 1
    }
    give back total
}

.char_at(i) returns a one-character word — there is no char type. Note and / or take bool only; there is no truthiness, so if count is an error where if count > 0 is fine.

Counting lines is the same shape of loop, with one adjustment so a trailing newline does not invent an extra line:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
task count_lines(text: word) -> int {
    if text.length() == 0 { give back 0 }
    pilot mut total: int = 1
    pilot mut i: int = 0
    repeat text.length() times {
        if text.char_at(i) == "\n" { total += 1 }
        i += 1
    }
    if text.char_at(text.length() - 1) == "\n" { total -= 1 }
    give back total
}

Step 5 — Colour#

There is no colour library. You build ANSI escape sequences as word values. \x1b is the ESC byte:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
pilot C_RESET = "\x1b[0m"
pilot C_BOLD  = "\x1b[1m"
pilot C_RED   = "\x1b[1;31m"
pilot C_GREEN = "\x1b[32m"

Then concatenate:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
say C_RED + "error" + C_RESET + ": no such file"

Windows consoles are switched into ANSI mode automatically — the runtime calls freak_enable_ansi() at the start of every FREAK program, on both backends. You do not need to do anything platform-specific.

The full code tables — attributes, 16 colours, backgrounds, 256-colour, truecolour, cursor control — are in Coloured output.

Step 6 — Respecting NO_COLOR#

Nothing does this for you. The trick the CLI uses is to make the constants pilot mut and blank them, so every call site keeps working unchanged:

Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.

FREAK
pilot mut C_RESET = "\x1b[0m"
pilot mut C_RED   = "\x1b[1;31m"

task configure_output() -> void {
    if process::env("NO_COLOR") != "" {
        C_RESET = ""
        C_RED = ""
    }
}

Call configure_output() first thing in main.

Careful

There is no TTY detection in V3, so your program cannot tell whether stdout is a pipe. Escapes are emitted even when output is redirected. Honouring NO_COLOR is the only politeness available.

The finished tool#

examples/tut_cli.fk compilesruns
-- Tutorial 2, finished program.
--
-- A small command-line tool: read a file, count its lines, words and
-- characters, then print a coloured report. Falls back to a sample file
-- it writes itself when given no argument, so it always has input.

pilot mut C_RESET = "\x1b[0m"
pilot mut C_BOLD  = "\x1b[1m"
pilot mut C_DIM   = "\x1b[2m"
pilot mut C_RED   = "\x1b[1;31m"
pilot mut C_GREEN = "\x1b[32m"
pilot mut C_CYAN  = "\x1b[36m"

-- Honour the NO_COLOR convention. Blanking the constants keeps every
-- call site working unchanged.
task configure_output() -> void {
    if process::env("NO_COLOR") != "" {
        C_RESET = ""
        C_BOLD = ""
        C_DIM = ""
        C_RED = ""
        C_GREEN = ""
        C_CYAN = ""
    }
}

task sample_path() -> word {
    pilot path: word = "tut_sample.txt"
    fs::write(path, "the beta do not negotiate\nhumanity answers with steel\nand with pilots\n")
    give back path
}

-- Which file are we working on? Argument 0 is the executable itself.
task target_path() -> word {
    if process::args_count() > 1 {
        give back process::arg(1)
    }
    give back sample_path()
}

task count_words(text: word) -> int {
    pilot mut total: int = 0
    pilot mut in_word: bool = false
    pilot mut i: int = 0

    repeat text.length() times {
        pilot c: word = text.char_at(i)
        pilot blank: bool = c == " " or c == "\n" or c == "\t" or c == "\r"
        if blank {
            in_word = false
        } else {
            if not in_word { total += 1 }
            in_word = true
        }
        i += 1
    }
    give back total
}

task count_lines(text: word) -> int {
    if text.length() == 0 { give back 0 }
    pilot mut total: int = 1
    pilot mut i: int = 0
    repeat text.length() times {
        if text.char_at(i) == "\n" { total += 1 }
        i += 1
    }
    -- A trailing newline does not start a new line.
    if text.char_at(text.length() - 1) == "\n" { total -= 1 }
    give back total
}

task row(label: word, value: int) -> void {
    say "  " + C_DIM + label + C_RESET + " " + C_BOLD + word_from_int(value) + C_RESET
}

task main() -> void {
    configure_output()

    pilot path: word = target_path()

    if not fs::exists(path) {
        say C_RED + "error" + C_RESET + ": no such file: " + path
        process::exit(1)
    }

    pilot text: word = fs::read(path)

    say C_CYAN + C_BOLD + "report" + C_RESET + C_DIM + " for " + path + C_RESET
    row("lines     ", count_lines(text))
    row("words     ", count_words(text))
    row("characters", text.length())

    if count_words(text) > 5 {
        say "  " + C_GREEN + "ok" + C_RESET + " enough material to work with"
    }

    fs::delete("tut_sample.txt")
}
Program output (ANSI rendered)
report for tut_sample.txt
  lines      3
  words      12
  characters 70
  ok enough material to work with

Run it against your own file:

shell
freak run wordcount.fk -- README.md
NO_COLOR=1 freak run wordcount.fk -- README.md

What you would reach for next, and cannot#

Worth knowing before you plan a bigger tool:

You might wantV3 reality
result<T, E> and ?None. Sentinels and process::exit
.split(",") into a liststring_split returns an encoded word, not a list. Walk characters
A map for counting wordsNo Map<K,V>. Two parallel collections and array_find
for each line in linesfor each walks a List<T> or an int range, not a word. Here, a counted loop plus an index
Writing to stderrNo stderr writer. Everything goes to stdout
A -o output flagfreak build -o NAME or --output=NAME. Default: the source basename

See Not in V3 for substitutes.

Badged executable examples were compiled and run with the released freak 0.15.1 (Maverick). 84/84 examples match their reviewed expected output. Illustrative snippets are labelled separately. Regenerate with python tools/refresh.py.

V4 boxes were checked against the development snapshot 7c9a1f3c27 (2026-10-04); 46/46 V4 examples match their reviewed expectation. Regenerate with python tools/verify_v4.py --checkout <Freak-lang>.