examples/logs/
Turn a log file into a readable summary.
- 589 lines
- 4 modules
- command line
- reads a file
A bit mask stores the selected log levels as bits in a number. The program uses bitwise operators to change and test those bits. It counts and skips lines it cannot read, then continues with the rest of the file.
Run it
lines: 12 read, 1 skipped
levels:
INFO 8
ERROR 2
WARN 2
sources:
cli 4
emit 3
lexer 2
resolve 1
syntax 1
types 1
busiest hour: 21
first error: emit: variable 't72' set but not used
exit 1 · 14 lines
The program
# A log summariser: a file of lines in, a page a person can read out.
#
# heroes run examples/logs/main.hero -- examples/logs/sample.log
# heroes run examples/logs/main.hero -- examples/logs/sample.log ERROR
#
# main.hero the program: arguments, the file, the page, the status
# summary.hero counting, and the ordering a count wants
# entry.hero one line, taken apart — and the lines that are not one
#
# It ends **1** when the log holds an error, which is the whole point of running
# it from a script: the exit code answers "did anything go wrong last night"
# without anyone reading the page.
#
# What it exercises: `{str: i64}` tallies read back in an order the map does not
# have, a ranking that `sort` gets wrong on purpose, and a reader that **skips
# and counts** what it cannot parse rather than refusing the file.
use entry
use mask
use summary
function main()
given = args()
if len(given) == 0
print("usage: logs <file.log> [LEVEL,LEVEL…]")
exit(2)
match read_file(given[0])
.err e => stop("cannot read " + given[0] + ": " + e.code)
.ok text => report(entry.read(text), given)
function stop(message: str)
print(message)
exit(1)
function report(r: entry.Reading, given: [str])
if len(given) > 1
match mask.read(given[1])
.err e => stop(e.code + ": " + e.msg)
.ok wanted => show_levels(r, wanted)
return
print("lines: ", r.entries.len(), " read, ", r.skipped.len(), " skipped")
print("levels:")
for line in summary.rows(summary.tally(summary.levels_of(r.entries)))
print(line)
print("sources:")
for line in summary.rows(summary.tally(summary.sources_of(r.entries)))
print(line)
busiest = summary.ranked(summary.tally(summary.hours_of(r.entries)))
if busiest.len() > 0
print("busiest hour: ", busiest[0])
match summary.first_at(r.entries, "ERROR")
.ok first => print("first error: ", first.source, ": ", first.message)
.err _ => print("first error: none")
if summary.how_many_at(r.entries, "ERROR") > 0
exit(1)
# Every line at one of the wanted levels, in the order the file had them — the
# view you want once the page above has told you which levels to ask about.
#
# The filter is one `&` per line rather than a loop over a list of names, which
# is what a set of flags is for: `logs f.log WARN,ERROR` and
# `mask.PROBLEMS` are the same question asked two ways.
function show_levels(r: entry.Reading, wanted: i64)
shown: i64 @= 0
for one in r.entries
if mask.holds(wanted, one.level)
shown @ shown + 1
print(one.stamp, " ", one.source, ": ", one.message)
print(shown, " ", join(mask.names_of(wanted), "+"), " lines")
test "the sample's page is the one the file describes"
r = entry.read(read_file("examples/logs/sample.log").must())
assert r.entries.len() == 12
assert r.skipped == [8]
assert summary.how_many_at(r.entries, "ERROR") == 2
assert summary.first_at(r.entries, "ERROR").must().source == "emit"
test "the sample's two hours are tied, so the tie-break is what is being read"
r = entry.read(read_file("examples/logs/sample.log").must())
hours = summary.tally(summary.hours_of(r.entries))
assert hours["21"].must() == hours["22"].must()
assert summary.ranked(hours) == ["21", "22"]
assert summary.rows(summary.tally(summary.levels_of(r.entries)))[0] == " INFO 8"
test "a mask filters the sample the way a list of names would"
r = entry.read(read_file("examples/logs/sample.log").must())
problems: [entry.Entry] @= []
for one in r.entries
if mask.holds(mask.PROBLEMS, one.level)
problems @ problems.push(one)
assert problems.len() == 4
assert summary.how_many_at(problems, "ERROR") == 2
assert summary.how_many_at(problems, "INFO") == 0
entry.hero · 165 lines
# One log line, taken apart — and the lines that are not one.
#
# 2026-08-13T22:04:11 ERROR emit: the thing broke
# └─ stamp ────────┘ └level┘ └src┘ └─ message ──┘
#
# **A line that does not fit is skipped and counted, never fatal.** A summariser
# that refuses a whole file because one line was truncated is a summariser nobody
# can use on a real log, where the last line is half-written as often as not. The
# count is reported, so "skipped" is a number the reader sees rather than a
# silence.
## Error codes
#
# One `constant` per code, so a typo is `error[unknown_name]` here rather
# than a comparison that is quietly false forever (panel 034 R4).
constant ERR_NO_SOURCE: str
"no_source"
constant ERR_NO_STAMP: str
"no_stamp"
constant ERR_SHORT_LINE: str
"short_line"
record Entry
stamp: str
level: str
source: str
message: str
record Reading
entries: [Entry]
skipped: [i64]
function is_space(c: u8) -> bool
return c == ' ' || c == '\t' || c == 13
function trimmed(line: str) -> str
from: i64 @= 0
while from < line.len() && is_space(line[from])
from @ from + 1
to: i64 @= line.len()
while to > from && is_space(line[to - 1])
to @ to - 1
return line.slice(from: from, to: to)
function split_lines(text: str) -> [str]
out: [str] @= []
start: i64 @= 0
i: i64 @= 0
while i < text.len()
if text[i] == '\n'
out @ out.push(text.slice(from: start, to: i))
start @ i + 1
i @ i + 1
if start < text.len()
out @ out.push(text.slice(from: start, to: text.len()))
return out
function index_of(text: str, c: u8) -> i64
i: i64 @= 0
while i < text.len()
if text[i] == c
return i
i @ i + 1
return 0 - 1
# The first `n` space-separated words, and everything left over as one piece.
# Splitting on every space would lose the message, which contains them.
function first_words(text: str, n: i64) -> [str]
out: [str] @= []
rest: str @= trimmed(text)
for _ in range(from: 0, to: n)
at = index_of(rest, ' ')
if at < 0
out @ out.push(rest)
rest @ ""
else
out @ out.push(rest.slice(from: 0, to: at))
rest @ trimmed(rest.slice(from: at + 1, to: rest.len()))
out @ out.push(rest)
return out
## One line
# `stamp LEVEL source: message`, or an error saying which part was missing.
function read_line(text: str) -> Entry?
parts = first_words(text, 3)
if parts[0] == "" || parts[1] == "" || parts[2] == ""
return fail(code: ERR_SHORT_LINE, msg: "a line is `stamp LEVEL source: message`")
if parts[2][parts[2].len() - 1] != ':'
return fail(code: ERR_NO_SOURCE, msg: "the third word must end in `:`")
if index_of(parts[0], 'T') < 0
return fail(code: ERR_NO_STAMP, msg: "the first word must be a timestamp, `DATEThh:mm:ss`")
source = parts[2].slice(from: 0, to: parts[2].len() - 1)
return ok(Entry(stamp: parts[0], level: parts[1], source: source, message: parts[3]))
# The whole file: what could be read, and the line numbers of what could not.
function read(text: str) -> Reading
entries: [Entry] @= []
skipped: [i64] @= []
number: i64 @= 0
for line in split_lines(text)
number @ number + 1
if trimmed(line) == ""
continue
match read_line(line)
.ok entry => entries @ entries.push(entry)
.err _ => skipped @ skipped.push(number)
return Reading(entries: entries, skipped: skipped)
# The hour a stamp names — `2026-08-13T22:04:11` is `22`. The two characters
# after the `T`, which is why `read_line` insists there is one.
function hour_of(entry: Entry) -> str
at = index_of(entry.stamp, 'T')
if at < 0 || at + 3 > entry.stamp.len()
return "??"
return entry.stamp.slice(from: at + 1, to: at + 3)
test "a line comes apart into four pieces, message included"
one = read_line("2026-08-13T22:04:11 ERROR emit: the thing broke, twice").must()
assert one.stamp == "2026-08-13T22:04:11"
assert one.level == "ERROR"
assert one.source == "emit"
assert one.message == "the thing broke, twice"
test "each way of not being a log line has its own code"
assert code_of("") == ERR_SHORT_LINE
assert code_of("2026-08-13T22:04:11 ERROR") == ERR_SHORT_LINE
assert code_of("2026-08-13T22:04:11 ERROR emit the thing") == ERR_NO_SOURCE
assert code_of("yesterday ERROR emit: the thing") == ERR_NO_STAMP
test "a file keeps what it could read and counts what it could not"
r = read("2026-08-13T09:00:00 INFO a: one\nnonsense\n\n2026-08-13T10:00:00 WARN b: two\n")
assert r.entries.len() == 2
assert r.skipped == [2]
test "the hour is the two characters after the T"
assert hour_of(read_line("2026-08-13T22:04:11 INFO a: x").must()) == "22"
assert hour_of(read_line("2026-08-13T07:04:11 INFO a: x").must()) == "07"
function code_of(text: str) -> str
return match read_line(text)
.ok _ => "no_error"
.err e => e.code
mask.hero · 185 lines
# The levels, as a set of bits.
#
# One bit per level, `|` to add, `&` to test, `~` to take away, `^` to toggle,
# `>>` to count. It is the shape a flag set has had since C, and it is the shape
# the six bitwise operators were argued from — a **flag union**, written `|`
# rather than `+`, where `+` is a silent wrong answer the moment one flag is
# added twice.
#
# Why a mask at all, rather than a `[str]` of level names: the question the
# program asks is *is this line in the set*, once per line, and a mask answers it
# with one `&` where a list answers it with a loop. A five-element list would be
# perfectly fast; the reason to write it this way is that a set of named flags is
# what a mask **is**, and the alternative spells the same idea in a shape that has
# to be read to be understood.
#
# `PROBLEMS` is the constant this buys: `WARN | ERROR` written once, named, and
# impossible to get wrong at a call site.
constant BIT_TRACE: i64
1
constant BIT_DEBUG: i64
1 << 1
constant BIT_INFO: i64
1 << 2
constant BIT_WARN: i64
1 << 3
constant BIT_ERROR: i64
1 << 4
# Every level there is. Written as a union rather than as `31`, because `31` is a
# number a reader has to decode and this is a list they can read.
constant EVERY: i64
BIT_TRACE | BIT_DEBUG | BIT_INFO | BIT_WARN | BIT_ERROR
# The two levels somebody woken at night is looking for.
constant PROBLEMS: i64
BIT_WARN | BIT_ERROR
constant ERR_NO_SUCH_LEVEL: str
"no_such_level"
# The bit a level's name stands for, or 0 for a name that is not a level. Zero is
# the right answer for "no bits at all" and it makes `holds` false for every
# mask, which is what a caller wants from a name that means nothing.
function bit_of(level: str) -> i64
if level == "TRACE"
return BIT_TRACE
if level == "DEBUG"
return BIT_DEBUG
if level == "INFO"
return BIT_INFO
if level == "WARN"
return BIT_WARN
if level == "ERROR"
return BIT_ERROR
return 0
# `mask & bit != 0` needs no parentheses, and that is worth one line of comment
# because it is the opposite of C. There `&` binds *looser* than `!=`, so
# `mask & bit != 0` means `mask & (bit != 0)` — the most famous precedence wart
# there is, and the reason C programmers write the parentheses by reflex. The
# spec's chain puts `&` above the comparisons, so what is written is what is
# meant.
function holds(mask: i64, level: str) -> bool
return mask & bit_of(level) != 0
function added(mask: i64, level: str) -> i64
return mask | bit_of(level)
function removed(mask: i64, level: str) -> i64
return mask & ~bit_of(level)
function toggled(mask: i64, level: str) -> i64
return mask ^ bit_of(level)
# How many levels are in the set, counted by walking the bits down. `>>` on a
# mask that is never negative, so the sign bit is not a question here.
#
# `n + (left & 1)` **does** need its parentheses, and the first version of this
# line did not have them: `+` binds tighter than `&` — in Heroes as in C — so
# `n + left & 1` is `(n + left) & 1`, which counts every mask as 1 or 0. Two
# tests caught it in the first run. The two lines above and below are the pair
# worth reading together: one place where C's parentheses are unnecessary here,
# one where they are just as necessary as there.
function how_many(mask: i64) -> i64
left: i64 @= mask
n: i64 @= 0
while left != 0
n @ n + (left & 1)
left @ left >> 1
return n
# The names in the set, in the order the levels are declared — which is the order
# of severity, and the one a reader expects a level list in.
function names_of(mask: i64) -> [str]
out: [str] @= []
for level in ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]
if holds(mask, level)
out @ out.push(level)
return out
# `ERROR,WARN` — a comma-separated list of level names, as one mask.
function read(text: str) -> i64?
mask: i64 @= 0
for name in split_on(text, ',')
if bit_of(name) == 0
return fail(
code: ERR_NO_SUCH_LEVEL,
msg: "`" + name + "` is not a level — try " + join(names_of(EVERY), ", ")
)
mask @ added(mask, name)
return ok(mask)
function split_on(text: str, c: u8) -> [str]
out: [str] @= []
start: i64 @= 0
i: i64 @= 0
while i <= text.len()
if i == text.len() || text[i] == c
piece = text.slice(from: start, to: i)
if piece != ""
out @ out.push(piece)
start @ i + 1
i @ i + 1
return out
test "a bit is a level, and a name that is not a level is no bits at all"
assert bit_of("ERROR") == BIT_ERROR
assert bit_of("TRACE") == 1
assert bit_of("SHOUT") == 0
assert holds(EVERY, "SHOUT") == false
test "the union is what the constants say it is"
assert PROBLEMS == BIT_WARN | BIT_ERROR
assert EVERY == 31
assert how_many(EVERY) == 5
assert how_many(PROBLEMS) == 2
assert how_many(0) == 0
test "adding twice is adding once, which is the whole reason it is not `+`"
once = added(0, "ERROR")
twice = added(once, "ERROR")
assert once == twice
assert once == BIT_ERROR
assert BIT_ERROR + BIT_ERROR != BIT_ERROR
test "taking away leaves the rest, and toggling puts it back"
without_error = removed(EVERY, "ERROR")
assert holds(without_error, "ERROR") == false
assert holds(without_error, "WARN") == true
assert how_many(without_error) == 4
assert toggled(without_error, "ERROR") == EVERY
test "the names come back in severity order, whatever order they went in"
assert names_of(PROBLEMS) == ["WARN", "ERROR"]
assert names_of(read("ERROR,TRACE").must()) == ["TRACE", "ERROR"]
assert names_of(0) == []
test "a list of names is a mask, and a name that is not a level says so"
assert read("ERROR").must() == BIT_ERROR
assert read("WARN,ERROR").must() == PROBLEMS
assert read("ERROR,,WARN").must() == PROBLEMS
match read("ERROR,SHOUT")
.ok _ => assert false
.err e =>
assert e.code == ERR_NO_SUCH_LEVEL
assert e.msg == "`SHOUT` is not a level — try TRACE, DEBUG, INFO, WARN, ERROR"
summary.hero · 132 lines
# Counting, and the ordering a count wants.
#
# Almost everything here is a `{str: i64}` filled in one pass and then read in an
# order the map does not have. That is the shape a `{K: V}` forces and it is
# worth seeing once: insertion order is not remembered, `==` ignores it, and
# `sort(keys(m))` gives the one order every run agrees on.
#
# **Sorted by count, ties by name.** `sort` orders text, which is the wrong order
# for a tally — `WARN` before `ERROR` says nothing — so the ranking is a small
# selection over the keys. Ties fall back to the name so two runs of the same
# file print the same page, which is what makes the output a golden at all.
use entry
## Error codes
#
# One `constant` per code, so a typo is `error[unknown_name]` here rather
# than a comparison that is quietly false forever (panel 034 R4).
constant ERR_NONE_AT_LEVEL: str
"none_at_level"
function tally(names: [str]) -> {str: i64}
counts: {str: i64} @= {}
for name in names
counts[name] @ counts[name].default(0) + 1
return counts
function levels_of(entries: [entry.Entry]) -> [str]
out: [str] @= []
for one in entries
out @ out.push(one.level)
return out
function sources_of(entries: [entry.Entry]) -> [str]
out: [str] @= []
for one in entries
out @ out.push(one.source)
return out
function hours_of(entries: [entry.Entry]) -> [str]
out: [str] @= []
for one in entries
out @ out.push(entry.hour_of(one))
return out
# The keys, most first. A selection over `sort(keys(m))`, so the starting order
# is already the tie-break and the loop only has to find the largest count.
function ranked(counts: {str: i64}) -> [str]
left: [str] @= sort(keys(counts))
out: [str] @= []
while left.len() > 0
best: i64 @= 0
for i in range(from: 1, to: left.len())
if counts[left[i]].default(0) > counts[left[best]].default(0)
best @ i
out @ out.push(left[best])
left @ without(left, best)
return out
function without(names: [str], at: i64) -> [str]
out: [str] @= []
for i in range(from: 0, to: names.len())
if i != at
out @ out.push(names[i])
return out
# `name n` per line, in ranked order — the shape every section of the report has.
function rows(counts: {str: i64}) -> [str]
out: [str] @= []
for name in ranked(counts)
out @ out.push(" " + name + " " + counts[name].default(0).to_str())
return out
# The first entry at this level, which is the one a reader opens the log to find.
function first_at(entries: [entry.Entry], level: str) -> entry.Entry?
for one in entries
if one.level == level
return ok(one)
return fail(code: ERR_NONE_AT_LEVEL, msg: "no line is " + level)
function how_many_at(entries: [entry.Entry], level: str) -> i64
return tally(levels_of(entries))[level].default(0)
test "a tally counts, and an absent key is zero rather than an error"
counts = tally(["a", "b", "a"])
assert counts["a"].must() == 2
assert counts["b"].must() == 1
assert counts["c"].default(0) == 0
test "the ranking is by count, and ties fall back to the name"
assert ranked(tally(["b", "a", "a"])) == ["a", "b"]
assert ranked(tally(["z", "a"])) == ["a", "z"]
assert ranked(tally([])) == []
test "the ranking is the one sort would get wrong"
counts = tally(["WARN", "ERROR", "ERROR", "ERROR"])
assert ranked(counts) == ["ERROR", "WARN"]
assert sort(keys(counts)) == ["ERROR", "WARN"]
assert rows(counts) == [" ERROR 3", " WARN 1"]
test "the first line at a level is the first one, not any one"
r = entry.read(
"2026-08-13T09:00:00 WARN a: one\n2026-08-13T10:00:00 ERROR b: two\n2026-08-13T11:00:00 ERROR c: three\n"
)
assert first_at(r.entries, "ERROR").must().source == "b"
assert first_at(r.entries, "TRACE").is_err()
assert how_many_at(r.entries, "ERROR") == 2
assert how_many_at(r.entries, "TRACE") == 0
test "the busiest hour is a rank over the hours"
r = entry.read(
"2026-08-13T09:00:00 INFO a: one\n2026-08-13T22:00:00 INFO a: two\n2026-08-13T22:30:00 INFO a: three\n"
)
assert ranked(tally(hours_of(r.entries)))[0] == "22"
Input
2026-08-13T21:58:02 INFO cli: heroes build examples/maze/main.hero
2026-08-13T21:58:02 INFO lexer: 412 tokens
2026-08-13T21:58:02 INFO syntax: 31 declarations
2026-08-13T21:58:03 WARN resolve: `grid` names a module, not a value
2026-08-13T21:58:03 ERROR emit: variable 't72' set but not used
2026-08-13T21:58:03 INFO cli: exit 1
2026-08-13T22:04:10 INFO cli: heroes build examples/maze/main.hero
this line was still being written when the process died
2026-08-13T22:04:11 INFO lexer: 412 tokens
2026-08-13T22:04:11 WARN emit: a temporary nobody reads
2026-08-13T22:04:12 ERROR emit: the assertion the header wanted
2026-08-13T22:04:13 INFO types: 0 diagnostics
2026-08-13T22:31:00 INFO cli: exit 0
fingerprint sha256 9f852c3ba20a · 7 files · index.json
Next: argv. All the examples. ← Previous: todo
Run one yourself: the four commands. Learn the language, or read the full specification in one file.