markz

EBNF

The notation docs/grammar.md is written in, read into a tree so the grammar can be checked (every name defined, every production reachable), generate documents (step 16) and judge them (step 18: recognizer). It is the W3C notation of the XML spec, kept small: name ::= expression, | for alternatives, juxtaposition for sequence, ?, * and +, parentheses, 'literal' or "literal", #xA for a character by code point, and [a-z] or [^…] for a character class, which may hold #x… too.

export type Expr =
  | { kind: "name"; name: string }
  | { kind: "literal"; text: string }
  | { kind: "class"; negated: boolean; ranges: [number, number][] }
  | { kind: "seq"; items: Expr[] }
  | { kind: "alt"; options: Expr[] }
  | { kind: "repeat"; op: "?" | "*" | "+"; item: Expr };

export interface Production {
  name: string;
  expr: Expr;
  /** The production's text, as written. */
  source: string;
}

const NAME = /[a-z][a-z\d-]*/y;
const HEX = /#x([\dA-Fa-f]+)/y;

/** Reads `name ::= …` productions. A line that doesn't start one continues the one before. */
export function productions(text: string): Production[] {
  const out: Production[] = [];
  const lines = text
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean);
  for (const line of lines) {
    const m = /^([a-z][a-z\d-]*)\s+::=\s+(.+)$/.exec(line);
    if (m) out.push({ name: m[1]!, expr: { kind: "name", name: "" }, source: m[2]! });
    else if (out.length) out.at(-1)!.source += ` ${line}`;
    else throw new Error(`not a production: ${line}`);
  }
  for (const p of out) p.expr = parse(p.source, p.name);
  return out;
}

function parse(source: string, name: string): Expr {
  let i = 0;
  const fail = (why: string): never => {
    throw new Error(`${name}: ${why} at ${i} in ${source}`);
  };
  const space = () => {
    while (source[i] === " ") i++;
  };
  const sticky = (re: RegExp) => {
    re.lastIndex = i;
    const m = re.exec(source);
    if (m) i = re.lastIndex;
    return m;
  };

  function alt(): Expr {
    const options = [seq()];
    for (space(); source[i] === "|"; space()) {
      i++;
      options.push(seq());
    }
    return options.length === 1 ? options[0]! : { kind: "alt", options };
  }

  function seq(): Expr {
    const items: Expr[] = [];
    for (space(); i < source.length && !"|)".includes(source[i]!); space()) {
      let item = primary();
      const op = source[i];
      if (op === "?" || op === "*" || op === "+") {
        i++;
        item = { kind: "repeat", op, item };
      }
      items.push(item);
    }
    if (!items.length) fail("empty alternative");
    return items.length === 1 ? items[0]! : { kind: "seq", items };
  }

  function primary(): Expr {
    const c = source[i];
    if (c === "(") {
      i++;
      const e = alt();
      if (source[i++] !== ")") fail("expected )");
      return e;
    }
    if (c === "'" || c === '"') {
      const close = source.indexOf(c, i + 1);
      if (close <= i + 1) fail("empty or unclosed literal");
      const text = source.slice(i + 1, close);
      i = close + 1;
      return { kind: "literal", text };
    }
    if (c === "[") return charClass();
    const hex = sticky(HEX);
    if (hex) {
      const code = parseInt(hex[1]!, 16);
      return { kind: "literal", text: String.fromCodePoint(code) };
    }
    const n = sticky(NAME);
    if (n) return { kind: "name", name: n[0] };
    return fail("unexpected character");
  }

  function charClass(): Expr {
    i++;
    const negated = source[i] === "^";
    if (negated) i++;
    const ranges: [number, number][] = [];
    const one = (): number => {
      const hex = sticky(HEX);
      if (hex) return parseInt(hex[1]!, 16);
      const code = source.codePointAt(i);
      if (code === undefined) fail("unclosed class");
      i += code! > 0xffff ? 2 : 1;
      return code!;
    };
    while (source[i] !== "]") {
      if (i >= source.length) fail("unclosed class");
      const from = one();
      let to = from;
      if (source[i] === "-" && source[i + 1] !== "]") {
        i++;
        to = one();
        if (to < from) fail("reversed range");
      }
      ranges.push([from, to]);
    }
    i++;
    if (!ranges.length) fail("empty class");
    return { kind: "class", negated, ranges };
  }

  const expr = alt();
  if (i < source.length) fail("unexpected )");
  return expr;
}

#Recognizing

Whether a string is a production, so the grammar can judge a case on its own rather than only write one. Every way to read each part is kept, as a set of where it may end, so an ambiguous grammar needs no backtracking, and each name's ends are remembered by where it starts. A repeat stops when an item matches nothing, so a * over something that may be empty still ends.

export function recognizer(
  defined: ReadonlyMap<string, { expr: Expr }>,
): (name: string, text: string) => boolean {
  return (name, text) => {
    const memo = new Map<string, number[]>();
    const ends = (expr: Expr, at: number): number[] => {
      switch (expr.kind) {
        case "name": {
          const key = `${expr.name}@${at}`;
          let found = memo.get(key);
          if (found) return found;
          // A name reached again at the same place, before it has ends, reads nothing there.
          memo.set(key, []);
          const p = defined.get(expr.name);
          if (!p) throw new Error(`no production ${expr.name}`);
          found = ends(p.expr, at);
          memo.set(key, found);
          return found;
        }
        case "literal":
          return text.startsWith(expr.text, at) ? [at + expr.text.length] : [];
        case "class": {
          const code = text.codePointAt(at);
          if (code === undefined) return [];
          const inside = expr.ranges.some(([a, b]) => code >= a && code <= b);
          return inside === expr.negated ? [] : [at + (code > 0xffff ? 2 : 1)];
        }
        case "seq": {
          let from = [at];
          for (const item of expr.items) {
            from = [...new Set(from.flatMap((f) => ends(item, f)))];
            if (!from.length) break;
          }
          return from;
        }
        case "alt":
          return [...new Set(expr.options.flatMap((o) => ends(o, at)))];
        case "repeat": {
          const out = new Set(expr.op === "+" ? [] : [at]);
          if (expr.op === "?") {
            for (const e of ends(expr.item, at)) out.add(e);
            return [...out];
          }
          let frontier = [at];
          const seen = new Set(frontier);
          while (frontier.length) {
            const next: number[] = [];
            for (const f of frontier) {
              for (const e of ends(expr.item, f)) {
                out.add(e);
                if (!seen.has(e)) {
                  seen.add(e);
                  next.push(e);
                }
              }
            }
            frontier = next;
          }
          return [...out];
        }
      }
    };
    return ends({ kind: "name", name }, 0).includes(text.length);
  };
}

/** Every name an expression refers to. */
export function references(expr: Expr, out = new Set<string>()): Set<string> {
  if (expr.kind === "name") out.add(expr.name);
  else if (expr.kind === "seq") for (const e of expr.items) references(e, out);
  else if (expr.kind === "alt") for (const e of expr.options) references(e, out);
  else if (expr.kind === "repeat") references(expr.item, out);
  return out;
}