Heroes / Esempi / logs

examples/logs/

Un programma che legge un file di log e ne produce un riepilogo.

Un log registra gli eventi di un programma. Questo esempio usa i singoli bit di un numero per scegliere quali livelli di messaggi includere. Quando trova una riga che non riesce a interpretare, la conta tra quelle scartate e continua con il resto del file.

Eseguilo

$ heroes run examples/logs/main.hero -- examples/logs/sample.log
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 righe

Il programma

# 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
examples/logs/main.hero, 107 righe
entry.hero · 165 righe
# 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
examples/logs/entry.hero, 165 righe
mask.hero · 185 righe
# 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"
examples/logs/mask.hero, 185 righe
summary.hero · 132 righe
# 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"
examples/logs/summary.hero, 132 righe

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
examples/logs/sample.log, 13 righe

impronta sha256 9f852c3ba20a · 7 file · index.json

Successivo: argv. Tutti gli esempi. ← Precedente: todo

Eseguine uno: i quattro comandi. Impara il linguaggio, oppure leggi la specifica completa in un solo file.