Lesson 06 / 27
Configuration Language
We define our own small configuration language and write its parser; the declaration, expression, and type structures the language carries are counted separately. It measures how many of eleven defects in a record without type declarations show up only at apply, how many get caught at parse time once types are added, and how line count and read hops move in opposite directions across three writings of the same target.
Published Updated
In this lesson
- Counts the configuration language's three structures separately: block declaration, expression, and type declaration
- Measures the type declaration's payoff with a single criterion — whether a defect shows up at parse time or at apply
- Ties the trade-off between line count and readability across different writings of the same target to numbers
Contents
The previous topic made drift measurable; the one thing that stayed unmeasured was the record
itself. This lesson defines our own small configuration language, writes a parser for it
with node, and measures three things: the declaration, expression, and type structures the
language carries; where an untyped record and a typed record catch the same defects; and the cost
of three ways of writing the same target. The measurement runs on the west environment of the
regional measurement network and is fictional; the language, the parser, and the target
system are a model built with node.
DP1. The fictional record carries two resources for the west environment: a machine cluster and a queue. DP2. The type declaration is a structure of the language, not of the target system; it runs before apply. DP3. Type checking does not compute an expression’s result; it only tests a value written as a constant. DP4. The target system model writes the record’s value as-is; it only rejects a non-number where a number is expected. DP5. If the value written to the target differs from what the record intends and no stage reports it, the defect counts as silent. DP6. A hop is the number of block references followed to find a field’s value.
The Language’s Three Structures
// lang.mjs — our language's tokenizer and parser. const TYPE = ["string", "number", "name", "punct", "comment", "unknown"]; const MARK = /"([^"]*)"|(-?\d+)|([a-z_][a-z0-9_]*)|([{}=,.()])|(#[^\n]*)|(\S)/g; export function tokenize(s) { const t = []; for (const m of s.matchAll(MARK)) { const i = m.slice(1).findIndex((x) => x !== undefined); if (i === 5) throw new Error(`unresolved mark "${m[0]}"`); if (i !== 4) t.push({ type: TYPE[i], value: m[i + 1] }); } return t; } export function parse(source) { const t = tokenize(source); let i = 0; const peek = () => t[i], next = () => t[i++]; const expect = (d) => { const x = next(); if (!x || x.value !== d) throw new Error(`expected "${d}", found "${x ? x.value : "end"}"`); }; const body = () => { const f = {}; // block body and map take the same path while (peek().value !== "}") { const a = next().value; expect("="); f[a] = expression(); if (peek().value === ",") next(); } next(); return f; }; function expression() { const x = next(); if (!x) throw new Error("record ended before expression finished"); if (x.type === "number") return { d: "number", value: Number(x.value) }; if (x.type === "string") return { d: "string", value: x.value }; if (x.value === "{") return { d: "map", fields: body() }; if (x.type !== "name") throw new Error(`unexpected "${x.value}"`); if (peek() && peek().value === "(") { next(); const arg = []; while (peek().value !== ")") { arg.push(expression()); if (peek().value === ",") next(); } next(); return { d: "call", name: x.value, arg }; } const path = [x.value]; while (peek() && peek().value === ".") { next(); path.push(next().value); } return { d: "path", path }; } const b = []; while (i < t.length) { const type = next().value, tag = []; while (peek() && peek().type !== "punct") tag.push(next().value); expect("{"); b.push({ type, tag, fields: body(), key: type === "resource" ? tag.join(".") : type }); } return b; }
// record.mjs — the fictional record of the west environment. export const ENVIRONMENT = `environment "west" { region = "west" collectors = 6 threshold_ms = 900 retention = 30 }`; export const BODY = ` resource machine "collector" { name = concat(environment.region, "-collector") count = environment.collectors cores = 4 memory_gb = 8 tags = { role = "meter-reading", region = environment.region } } resource queue "meter" { name = concat(environment.region, "-meter-queue") partitions = multiply(environment.collectors, 2) retention_days = environment.retention format = "binary" threshold_ms = environment.threshold_ms }`; export const TYPES = ` type environment { region = string collectors = number threshold_ms = number retention = number } type machine { name = string count = range(1, 64) cores = range(1, 64) memory_gb = range(1, 256) tags = map } type queue { name = string partitions = range(1, 128) retention_days = range(1, 365) format = set("binary", "text") threshold_ms = number }`;
The language has a single declaration form: a type word, tags, and field assignments inside braces. The right-hand side can be a constant, a reference to another block’s field, a call, or a map; there are no operators, and even multiplication is a call. This is the smallest form that can carry the measurement. The type declaration is written with the same syntax.
Untyped Record, Typed Record
// check.mjs — name resolution, type checking, apply model. import { parse } from "./lang.mjs"; const FN = { concat: (...a) => a.join(""), multiply: (a, b) => a * b, select: (m, k) => m[k] }; const children = (e) => e.d === "call" ? e.arg : e.d === "map" ? Object.values(e.fields) : []; export const print = (w, ...s) => console.log(s.map((x) => String(x).padEnd(w)).join("").trimEnd()); export const nodes = (e) => [e, ...children(e).flatMap(nodes)]; export const depth = (e) => 1 + Math.max(0, ...children(e).map(depth)); export const isConstant = (e) => e.d === "number" || e.d === "string"; export const kind = (b) => b.type === "resource" ? b.tag[0] : b.type; export const indexBy = (b) => new Map(b.map((x) => [x.key, x])); export const extractSchema = (m) => Object.fromEntries(parse(m).map((b) => [b.tag[0], Object.fromEntries(Object.entries(b.fields).map(([a, e]) => [a, e.d === "path" ? { base: e.path[0] } : e.name === "range" ? { base: "number", range: e.arg.map((x) => x.value) } : { base: "string", set: e.arg.map((x) => x.value) }]))])); export function resolve(path, idx) { for (let n = path.length - 1; n >= 1; n--) { const b = idx.get(path.slice(0, n).join(".")), a = path.slice(n).join("."); if (!b) continue; if (!(a in b.fields)) throw new Error(`${b.key} has no ${a}`); return { block: b, field: a }; } throw new Error(`${path.join(".")} does not match any block`); } export function checkTypes(blocks, schema) { // only runs on the typed record const errs = []; for (const b of blocks) { const t = schema[kind(b)]; for (const a of Object.keys(t)) if (!(a in b.fields)) errs.push(`${b.key}.${a} missing`); for (const [a, e] of Object.entries(b.fields)) { const k = t[a], y = `${b.key}.${a}`; if (!k) errs.push(`${y} untyped`); else if (!isConstant(e)) continue; // a computed value cannot be read from the record else if (e.d !== k.base) errs.push(`${y} not ${k.base}`); else if (k.range && (e.value < k.range[0] || e.value > k.range[1])) errs.push(`${y} out of range`); else if (k.set && !k.set.includes(e.value)) errs.push(`${y} out of set`); } } return errs; } export function evaluate(e, idx) { if (isConstant(e)) return e.value; if (e.d === "map") return Object.fromEntries(Object.entries(e.fields).map(([a, v]) => [a, evaluate(v, idx)])); if (e.d === "call") return FN[e.name](...e.arg.map((x) => evaluate(x, idx))); const c = resolve(e.path, idx); return evaluate(c.block.fields[c.field], idx); } export function apply(blocks, schema) { // target system model const idx = indexBy(blocks); return Object.fromEntries(blocks.filter((x) => x.type === "resource").flatMap((b) => Object.entries(b.fields).map(([a, e]) => { const v = evaluate(e, idx), y = `${b.key}.${a}`; if (schema[kind(b)][a]?.base === "number" && !Number.isFinite(v)) throw new Error(`${y} not a number`); return [y, typeof v === "object" ? JSON.stringify(v) : v]; }))); }
// defects.mjs — three structures and eleven defects are counted. import { parse } from "./lang.mjs"; import { ENVIRONMENT, BODY, TYPES } from "./record.mjs"; import { nodes, isConstant, indexBy, resolve, checkTypes, apply, extractSchema, print } from "./check.mjs"; const SCHEMA = extractSchema(TYPES), RECORD = ENVIRONMENT + BODY; const blocks = parse(RECORD), BASE = apply(blocks, SCHEMA); const assignments = blocks.flatMap((x) => Object.values(x.fields)); const allNodes = assignments.flatMap(nodes), typeFields = Object.values(SCHEMA).flatMap(Object.values); const count = (t) => `${allNodes.filter((e) => e.d === t).length} ${t}`; console.log(`declaration ${blocks.length} blocks, ${assignments.length} assignments, ${assignments.filter((e) => !isConstant(e)).length} computed`); console.log(`expression ${allNodes.length} nodes = ` + ["number", "string", "path", "call", "map"].map(count).join(" + ")); console.log(`type ${Object.keys(SCHEMA).length} kinds, ${typeFields.length} field declarations, ` + `${typeFields.filter((k) => k.range || k.set).length} constrained\n`); const corrupt = (field, next) => next === null // a one-line defect ? RECORD.replace(new RegExp(`\n *${field} *=[^\n]*`), "") : RECORD.replace(new RegExp(`(\n *)${field}( *=)[^\n]*`), next[0] === "@" ? `$1${next.slice(1)}` : `$1${field}$2 ${next}`); const DEFECTS = [ ["type mismatch", "cores", '"4"'], ["missing field", "memory_gb", null], ["wrong field name", "retention_days", "@retention = 30"], ["out-of-set value", "format", '"raw"'], ["out-of-range constant", "cores", "128"], ["reference typo", "count", "environment.collector"], ["nonexistent field", "threshold_ms", "machine.collector.core_count"], ["syntax", "format", 'binary"'], ["reference to wrong field", "count", "environment.threshold_ms"], ["computed overshoot", "retention_days", "multiply(environment.retention, 20)"], ["computed zero", "threshold_ms", "multiply(environment.threshold_ms, 0)"], ]; function stage(text, typed) { // which stage the defect is caught at let b; try { b = parse(text); const idx = indexBy(b); for (const x of b) for (const e of Object.values(x.fields)) nodes(e).filter((y) => y.d === "path").forEach((y) => resolve(y.path, idx)); } catch (e) { return ["parse", e.message]; } if (typed) { const t = checkTypes(b, SCHEMA); if (t.length) return ["type", t[0]]; } let g; try { g = apply(b, SCHEMA); } catch (e) { return ["apply: error", e.message]; } const f = Object.keys(BASE).filter((k) => BASE[k] !== g[k]); return f.length ? ["apply: silent", `${f[0]} ${BASE[f[0]]} instead of ${g[f[0]]}`] : ["passed", "-"]; } console.log(`base record ${stage(RECORD, true)[0]}, ${Object.keys(BASE).length} fields written to target`); print(27, "defect", "untyped record", "typed record", "typed record's mark in apply"); const tally = { A: {}, B: {} }; for (const [name, field, next] of DEFECTS) { const m = corrupt(field, next), [a] = stage(m, false), [b, mark] = stage(m, true); tally.A[a] = (tally.A[a] || 0) + 1; tally.B[b] = (tally.B[b] || 0) + 1; print(27, name, a, b, b[0] === "a" ? mark : ""); } console.log(); print(27, "stage", "untyped", "typed"); for (const a of ["parse", "type", "apply: error", "apply: silent"]) print(27, a, tally.A[a] || 0, tally.B[a] || 0);
declaration 3 blocks, 14 assignments, 7 computed expression 22 nodes = 6 number + 5 string + 7 path + 3 call + 1 map type 3 kinds, 14 field declarations, 6 constrained base record passed, 10 fields written to target defect untyped record typed record typed record's mark in apply type mismatch apply: error type missing field apply: silent type wrong field name apply: silent type out-of-set value apply: silent type out-of-range constant apply: silent type reference typo parse parse nonexistent field parse parse syntax parse parse reference to wrong field apply: silent apply: silent machine.collector.count 6 instead of 900 computed overshoot apply: silent apply: silent queue.meter.retention_days 30 instead of 600 computed zero apply: silent apply: silent queue.meter.threshold_ms 900 instead of 0 stage untyped typed parse 3 3 type 0 5 apply: error 1 0 apply: silent 7 3
Three blocks carry fourteen assignments; seven are computed expressions, and eleven of the twenty-two expression nodes are constants. The type side writes fourteen field declarations for three kinds — if ten resources of the same kind had been written, those same fourteen declarations would check a hundred and forty assignments. The type’s cost is paid per kind, not per resource.
Three defects show up independent of typing: broken syntax, a reference that matches no block, and a nonexistent field on a block that does exist; name resolution alone catches these. The remaining eight defects fall through to apply in the untyped record, and seven of them write a different value to the target without raising an error. The type declaration pulls five of those back to parse time; the defects that fall through to apply drop to three, and the silent defects drop to three. The gain is distance: a defect caught at parse time never touches a single resource.
In the three defects left standing, the value is not a constant but an expression: nine hundred machines instead of six, six hundred days of retention instead of thirty, a zero threshold instead of nine hundred milliseconds. The type gets the base right for all three, because they are all numbers; it cannot test the range, because the value does not exist yet while the record is being read. This is what the record does not cover.
Expressive Power versus Readability
// style.mjs — the same target measured across three forms. import { parse } from "./lang.mjs"; import { BODY, TYPES } from "./record.mjs"; import { extractSchema, nodes, depth, isConstant, resolve, indexBy, kind, apply, print } from "./check.mjs"; const SCHEMA = extractSchema(TYPES), REGIONS = ["west", "east", "central"], COUNTS = { west: 6, east: 6, central: 4 }; const MID = BODY.split("\n}\n")[0] + "\n}"; // the machine block from the record, unchanged const midEnvironment = (o) => `environment "${o}" { region = "${o}" collectors = ${COUNTS[o]} }`; const tightEnvironment = (o) => `environment "${o}" { region = "${o}" }`; const FLAT = ` resource machine "collector" { name = "west-collector" count = 6 cores = 4 memory_gb = 8 tags = { role = "meter-reading", region = "west" } }`; const TIGHT = ` local "scale" { base = 2 factor = { west = 3, east = 3, central = 2 } cores = { west = 4, east = 4, central = 4 } } resource machine "collector" { name = concat(environment.region, "-collector") count = multiply(local.base, select(local.factor, environment.region)) cores = select(local.cores, environment.region) memory_gb = multiply(local.base, multiply(select(local.cores, environment.region), 1)) tags = { role = "meter-reading", region = environment.region } }`; const lineCount = (m) => m.split("\n").filter((s) => s.trim()).length; const hops = (e, idx) => nodes(e).filter((x) => x.d === "path") .reduce((t, x) => { const c = resolve(x.path, idx); return t + 1 + hops(c.block.fields[c.field], idx); }, 0); const signature = (m) => JSON.stringify(apply(parse(m), SCHEMA)); const measure = (name, texts, lines, perEnv) => { const a = texts.flatMap((m) => { const b = parse(m), idx = indexBy(b); return b.filter((x) => x.type === "resource").flatMap((k) => Object.entries(k.fields).map(([n, e]) => [depth(e), hops(e, idx), (SCHEMA[kind(k)][n].range || SCHEMA[kind(k)][n].set) && !isConstant(e) ? 1 : 0])); }); const maxOf = (i) => Math.max(...a.map((x) => x[i])); print(14, name, lines, perEnv, maxOf(0), maxOf(1), a.reduce((s, x) => s + x[2], 0)); }; console.log(`three forms produce the same target: ${signature(FLAT) === signature(midEnvironment("west") + MID) && REGIONS.every((o) => signature(tightEnvironment(o) + TIGHT) === signature(midEnvironment(o) + MID)) ? "yes" : "no"}\n`); print(14, "form", "lines", "per env", "deepest", "hops", "type blind to"); measure("flat", [FLAT], lineCount(FLAT) * 3, lineCount(FLAT)); measure("mid", REGIONS.map((o) => midEnvironment(o) + MID), lineCount(MID) + 3, 1); measure("tight", REGIONS.map((o) => tightEnvironment(o) + TIGHT), lineCount(TIGHT) + 3, 1);
three forms produce the same target: yes form lines per env deepest hops type blind to flat 21 7 2 0 0 mid 10 1 2 1 3 tight 15 1 4 3 9
The three forms produce the same fifteen fields with the same values across three environments. The flat form has no hops, and the field the type is blind to is zero; its cost is twenty-one lines for three environments and seven lines for every new environment: eighty-four across twelve environments.
The mid form brings the same target down to ten lines and drops the cost of a new environment to one; nineteen lines across twelve environments. In return it costs one hop and three fields the type is blind to. The tight form goes the other way: the line count rises to fifteen, because it also writes the extra block that carries the maps; the deepest expression rises to four, hops to three, and the fields the type is blind to rise to nine — nine of the fifteen fields cannot be tested while the record is being read. Expressive power reduces repetition, not lines, and it reduces auditability outright.
Summary
- The fictional record carried 3 blocks, 14 assignments, and 22 expression nodes; 7 assignments were computed, and the type wrote 14 field declarations for 3 kinds.
- In the untyped record, 3 of 11 defects showed up at parse time, and 8 fell through to apply; 7 of them wrote a different value to the target without raising an error.
- The type declaration pulled 5 of those 8 back to parse time; the defects falling through to apply dropped to 3, and silent defects dropped to 3.
- The value in the remaining 3 defects was an expression; 900 was written instead of 6, 600 days instead of 30, and a 900 ms threshold became 0.
- The three forms took 21, 10, and 15 lines; hops were 0, 1, and 3, and the fields the type is blind to were 0, 3, and 9; the cost of a new environment was 7 lines versus 1.
Next Step
The target system here was a five-line model. In reality, creating, reading, updating, and deleting a resource are separate behaviors, and the layer that ties them to the record is called the provider. The next lesson models the provider as an interface: which field, when it changes, keeps the resource from updating in place and forces it to be recreated, and how many fields’ defaults shift when the provider version changes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.