Parsing configuration files

A config file is a tiny language, and almost every bug in reading one is a splitting bug.

The idea

Nearly every settings format is the same three shapes repeated: a comment, a section header, and a key with a value. So the parser is a loop over lines that asks which of those three a line is, and then records it.

The one rule that carries most of the correctness is this: split on the first separator, not on every one. A connection string, a URL, a base64 blob, a sentence: values contain separators all the time, and a parser that splits on all of them quietly truncates the value.

Then two things turn the loop into a real parser. Quoting, because a comment marker inside quotes is data. And references, because a value that mentions another key has to be resolved after the whole file is read, which is also where a file can accidentally refer to itself forever.

The current config line with its first separator marked, and the reference graph during resolution

the file

the settings map

How it works

One pass to read the file, a second pass to resolve references. Keeping them separate is what lets a value refer to a key defined further down, and it is why the last assignment wins even for a reference written above it.

def parse(lines):
    settings, section = {}, ""
    for raw in lines:
        line = strip_comment(raw).strip()
        if not line:
            continue
        if line.startswith("[") and line.endswith("]"):
            section = line[1:-1].strip()
            continue
        if "=" not in line:
            continue
        key, _, value = line.partition("=")       # FIRST "=" only
        settings[(section, key.strip())] = value.strip()
    return settings

def strip_comment(line):
    out, quoted = [], False
    for ch in line:
        if ch == '"':
            quoted = not quoted                    # a "#" inside quotes is data
        elif ch == "#" and not quoted:
            break
        out.append(ch)
    return "".join(out)

def resolve(settings, key, seen=None):
    seen = seen or set()
    if key in seen:
        raise ValueError(f"reference cycle at {key[1]}")
    seen = seen | {key}
    def sub(m):
        target = (key[0], m.group(1))              # same section
        return resolve(settings, target, seen) if target in settings else m.group(0)
    return re.sub(r"\$\{(\w+)\}", sub, settings[key])

Note what partition buys over split("="): it always returns three parts and never loses the tail. split("=", 1) is the same idea; split("=") is the bug.

Cost

PassTimeSpaceNote
Read and classifyO(total characters)O(keys)One left-to-right scan, no backtracking
Resolve referencesO(keys + references)O(depth)A graph walk, so it is cycle detection
Diff two filesO(k log k)O(k)The sort for stable output, not the compare

The interesting cost is never the time. It is that a value is a string until something decides otherwise, and every decision about types, trimming, and quoting has to be made explicitly or a later reader will make a different one.

Watch out for

Worked example

Take the line motd = "welcome # to prod" in a file where # starts a comment.

A naive parser cuts at the first # and stores "welcome, an unterminated quote and a lost sentence. A quote-aware scan walks the line character by character, flips a quoted flag on each ", and only treats a # as a comment when that flag is false. The whole value survives.

Now add url = https://${host}:${port}/v1 above a line that reassigns port to 9090. Because resolution runs after the read, ${port} picks up 9090, not the value that was in scope when the parser walked past that line. That is the behaviour you want, and it is only true if the two passes are actually separate.

Check yourself

A file contains path=/usr/bin:/opt/bin and note = a = b. What does a correct parser store?