examples/ini/
Read a configuration file into sections and report invalid entries.
- 247 lines
The program stores each section’s keys and values in a map, then stores those maps by section name. It reports malformed lines, unclosed section brackets, empty section names, empty keys and duplicate keys. Each error is a value with its own code, so the caller can handle it.
Run it
,client,server
localhost
8080
30
3
true
true
true
true
true
exit 0 · 10 lines
The program
# A configuration file read into sections — nested maps, and the four ways a
# config file lies to you.
#
# Corpus and regression material (CLAUDE.md §9). It closes no measured form
# gap; it is here for a shape and for a refusal. The shape is
# `{str: {str: str}}`, built from text rather than written out as a literal
# the way `examples/board/` does — which is the difference between a nested
# map the program declares and one it discovers.
#
# THE REFUSAL IS THE INTERESTING HALF. A config parser has four places to be
# quietly wrong, and every one of them is a real bug somebody has shipped:
#
# 1. a key before any section — silently dropped, or silently global?
# 2. the same key twice — first wins, or last?
# 3. the same SECTION twice — merged, or replaced?
# 4. a line that is neither — ignored, or refused?
#
# This one answers: a key before any section goes into `""`, the empty
# section, so it is reachable rather than lost; a repeated key is a FAILURE,
# because the writer meant one of them and nobody can tell which; a repeated
# section MERGES, because that is what every real format does; and a line
# that parses as nothing is a failure with its line number, because a config
# file silently ignoring a typo is how a service starts with the wrong port.
#
# Choice 2 is the one worth arguing about, and it is deliberate: `last wins`
# is convenient and it is how a value gets overridden by accident.
record Config
sections: {str: {str: str}}
function parse(text: str) -> Config?
sections: {str: {str: str}} @= {}
current: str @= ""
empty: {str: str} = {}
sections[current] @ empty
number: i64 @= 0
for line in split_lines(text)
number @ number + 1
trimmed = without_spaces(line)
if trimmed.len() == 0
continue
if starts_with(text: trimmed, prefix: "#") || starts_with(text: trimmed, prefix: ";")
continue
if starts_with(text: trimmed, prefix: "[")
name = section_name(trimmed)?
current @ name
# Merging rather than replacing: the section may already exist,
# and every format in use does this.
if sections[current].is_err()
fresh: {str: str} = {}
sections[current] @ fresh
continue
at = index_of_equals(trimmed)
if at.is_err()
return fail(
code: "not_a_line",
msg: "line " + number.to_str() + " is neither a section, a comment, nor a key"
)
key = without_spaces(trimmed.slice(from: 0, to: at.must()))
value = without_spaces(trimmed.slice(from: at.must() + 1, to: trimmed.len()))
if key.len() == 0
return fail(code: "empty_key", msg: "line " + number.to_str() + " has a value and no key")
inner: {str: str} @= sections[current].default(empty)
if !inner[key].is_err()
return fail(
code: "repeated_key",
msg: "`" + key + "` is set twice in section `" + current + "`, and only the writer knows which was meant"
)
inner[key] @ value
sections[current] @ inner
return ok(Config(sections: sections))
function section_name(line: str) -> str?
if !ends_with(text: line, suffix: "]")
return fail(code: "unclosed_section", msg: "a `[` with no `]`")
inner = without_spaces(line.slice(from: 1, to: line.len() - 1))
if inner.len() == 0
return fail(code: "empty_section", msg: "`[]` names nothing")
return ok(inner)
function value_of(c: Config, section: str, key: str) -> str?
empty: {str: str} = {}
return c.sections[section].default(empty)[key]
# Every section that holds at least one key, sorted.
#
# ORDER: sort(keys(c.sections)) — the list is returned to a caller that
# prints it, so the order is observed. The empty section is dropped when it
# holds nothing, which is why this is not just `keys`.
function section_names(c: Config) -> [str]
out: [str] @= []
for name in sort(keys(c.sections))
if c.sections[name].must().len() > 0
out @ out.push(name)
return out
function split_lines(text: str) -> [str]
out: [str] @= []
piece: str @= ""
for ch in text.chars()
if ch == "\n"
out @ out.push(piece)
piece @ ""
else
piece @ piece + ch
if piece.len() > 0
out @ out.push(piece)
return out
function without_spaces(text: str) -> str
first: i64 @= 0
last: i64 @= text.len()
while first < last && text[first] == ' '
first @ first + 1
while last > first && text[last - 1] == ' '
last @ last - 1
return text.slice(from: first, to: last)
function starts_with(text: str, prefix: str) -> bool
if prefix.len() > text.len()
return false
return text.slice(from: 0, to: prefix.len()) == prefix
function ends_with(text: str, suffix: str) -> bool
if suffix.len() > text.len()
return false
return text.slice(from: text.len() - suffix.len(), to: text.len()) == suffix
function index_of_equals(text: str) -> i64?
i: i64 @= 0
while i < text.len()
if text[i] == '='
return ok(i)
i @ i + 1
return fail(code: "no_equals", msg: "no `=` on the line")
constant SAMPLE: str
"verbose = true\n\n[server]\nhost = localhost\nport = 8080\n\n# a comment\n[client]\nretries = 3\n\n[server]\ntimeout = 30\n"
function main()
c = parse(SAMPLE).must()
print(join(section_names(c), ","))
print(value_of(c, section: "server", key: "host").must())
print(value_of(c, section: "server", key: "port").must())
print(value_of(c, section: "server", key: "timeout").must())
print(value_of(c, section: "client", key: "retries").must())
# A key before any section is reachable rather than lost.
print(value_of(c, section: "", key: "verbose").must())
# And the four refusals.
print(parse("host = a\nhost = b\n").is_err())
print(parse("[unclosed\n").is_err())
print(parse("[]\n").is_err())
print(parse("nonsense\n").is_err())
test "the ordinary case, with sections and keys"
c = parse("[a]\nx = 1\n[b]\ny = 2\n").must()
assert value_of(c, section: "a", key: "x").must() == "1"
assert value_of(c, section: "b", key: "y").must() == "2"
assert section_names(c) == ["a", "b"]
test "spaces around a key or a value are not part of it"
c = parse("[a]\n x = 1 \n").must()
assert value_of(c, section: "a", key: "x").must() == "1"
test "a value may be empty, and a key may not"
c = parse("[a]\nx =\n").must()
assert value_of(c, section: "a", key: "x").must() == ""
assert parse("[a]\n= 1\n").is_err()
test "a value may contain an equals sign, because only the first one counts"
c = parse("[a]\nx = y=z\n").must()
assert value_of(c, section: "a", key: "x").must() == "y=z"
test "a key before any section lands in the empty section rather than nowhere"
c = parse("global = yes\n[a]\nx = 1\n").must()
assert value_of(c, section: "", key: "global").must() == "yes"
assert value_of(c, section: "a", key: "x").must() == "1"
# And it is not reported as a section, because it has a name nobody wrote.
assert section_names(c) == ["", "a"]
test "a repeated section merges rather than replacing"
c = parse("[a]\nx = 1\n[b]\ny = 2\n[a]\nz = 3\n").must()
assert value_of(c, section: "a", key: "x").must() == "1"
assert value_of(c, section: "a", key: "z").must() == "3"
assert value_of(c, section: "b", key: "y").must() == "2"
assert section_names(c) == ["a", "b"]
test "a repeated key is a failure, which is the choice this file argues for"
# `last wins` is convenient and it is how a value gets overridden by
# accident. Refusing costs the writer one edit and costs a reader nothing.
assert parse("[a]\nx = 1\nx = 2\n").is_err()
assert parse("x = 1\nx = 2\n").is_err()
# The same key in two DIFFERENT sections is not a repeat.
assert !parse("[a]\nx = 1\n[b]\nx = 2\n").is_err()
test "comments and blank lines are skipped, in both spellings"
c = parse("# one\n; two\n\n[a]\nx = 1\n\n").must()
assert value_of(c, section: "a", key: "x").must() == "1"
assert section_names(c) == ["a"]
test "a line that is nothing at all is refused, with its number"
# A config file that silently ignores a typo is how a service starts on
# the wrong port.
assert parse("nonsense\n").is_err()
assert parse("[a]\nfine = 1\nnonsense\n").is_err()
assert parse("[unclosed\n").is_err()
assert parse("[]\n").is_err()
test "an empty file is an empty config and not a failure"
c = parse("").must()
assert section_names(c) == []
assert value_of(c, section: "a", key: "x").is_err()
assert value_of(c, section: "", key: "x").is_err()
test "the sample's repeated section is merged and its early key is reachable"
c = parse(SAMPLE).must()
assert value_of(c, section: "server", key: "host").must() == "localhost"
assert value_of(c, section: "server", key: "timeout").must() == "30"
assert value_of(c, section: "", key: "verbose").must() == "true"
assert section_names(c) == ["", "client", "server"]
fingerprint sha256 901611e3611a · 2 files · index.json
Next: template. All the examples. ← Previous: csv
Run one yourself: the four commands. Learn the language, or read the full specification in one file.