// @ts-check /* Commit records, parsed from a commit message and drawn as HTML. The * caller (commitRecords.js) reads each commit's identity and message from * git. The * format is the `records` skill's (.agents/skills/records/SKILL.md): a * subject, a `Why:` section, object blocks such as * `AST modified (raw extensions):`, each followed by an optional `Example:` * program and its forms as `Before (
):` and `After ():` blocks, * `Review:` and `Breaking:` sections, and the trailers. */ const REPO_URL = "https://github.com/rescript-lang/rescript"; /** @param {string} s */ export const esc = s => s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); // --------------------------------------------------------------------------- // The forms a record writes an object in, and how each is drawn /** * How a form is drawn. * @typedef {"math" | "tree" | "matrix" | "mapping" | "rescript" | "js" | "lambda" | "text" | "report"} Drawing */ /** * Each form's drawing, by the name a `Before ():` or `After ():` * block gives it: a compiler's printed tree or code, a diagnostic, or a * hand-written typing rule, clause matrix or representation mapping, or * reanalyze's report. A form * of another name is drawn as text. * @type {Record} */ export const FORMS = { parse: "tree", expanded: "tree", typed: "tree", lambda: "lambda", js: "js", rescript: "rescript", diagnostic: "text", text: "text", rule: "math", matrix: "matrix", representation: "mapping", reanalyze: "report", }; /** * An object block's heading: ` ()`, * the kind one word, the object's parentheses optional. */ export const BLOCK = /^([A-Z][A-Za-z]*) (added|modified|removed)(?: \(((?:[^()]|\([^()]*\))*)\))?$/; /** A section an object block holds: its example or one version of a form. */ export const PART = /^(?:Example|(Before|After) \(([a-z]+)\))$/; // --------------------------------------------------------------------------- // Records /** * @typedef {{ name: string, text: string[], block: string[] }} Section * @typedef {{ * sha: string, author: string, login?: string, date: string, message: string * }} CommitInfo * @typedef {CommitInfo & { * subject: string, sections: Section[], coauthors: string[] * }} CommitRecord * @typedef {{ range?: string, records: CommitRecord[] }} Group */ /** The sections a message names besides object blocks and their parts. */ const NAMED = new Set(["Why", "Review", "Breaking"]); /** * A trailer line: `Key: value`, the key one word of letters, digits and * hyphens other than a section's name. * @param {string} l */ const isTrailer = l => { const key = /^([A-Za-z][A-Za-z0-9-]*):\s+\S/.exec(l)?.[1]; return key !== undefined && !NAMED.has(key) && key !== "Example"; }; /** * The line a message's trailers start at: the first of its last lines that * are trailers or lines continuing one (starting with a space), the run * starting at a trailer. * @param {string[]} lines */ function trailerStart(lines) { let start = lines.length; for (let k = lines.length - 1; k > 0; k--) { if (isTrailer(lines[k])) start = k; else if (!/^\s+\S/.test(lines[k])) break; } return start; } /** * A line with the tabs of its indentation expanded to stops 4 columns apart. * @param {string} l */ const untab = l => l.replace(/^[ \t]+/, ws => { let col = 0; for (const c of ws) col = c === "\t" ? col + 4 - (col % 4) : col + 1; return " ".repeat(col); }); /** Whether a line is indented by 4 spaces or more. @param {string} l */ export const isIndented = l => /^ {4,}\S/.test(l); /** * A record's subject, sections and trailers. A section starts at a line * `Name:`, where the name is an object block's heading, a version heading, * `Why`, `Review` or `Breaking` outside a paragraph of prose, or `Example` * alone on its line; it holds the text after its colon and the lines * indented by 4 spaces under it. A blank line ends a section that holds * only text. Unnamed sections hold the paragraphs and indented blocks * between. * @param {string} text */ function parseMessage(text) { const lines = text.trimEnd().split("\n").map(untab); const end = trailerStart(lines); /** @type {Section[]} */ const sections = []; /** @type {Section | undefined} */ let current; const prose = () => { /** @type {Section} */ const s = { name: "", text: [], block: [] }; sections.push(s); return s; }; for (const line of lines.slice(1, end)) { const head = /^(\S(?:[^:]|\([^()]*\))*):(?:\s+(.*))?$/.exec(line); const name = head?.[1] ?? ""; // `Why:`, `Review:` or `Breaking:` that prose wraps onto a line of its // paragraph continues the paragraph. const inParagraph = current && !current.name && current.text.length && !current.block.length; if ( head && (BLOCK.test(name) || (PART.test(name) && !(name === "Example" && head[2])) || (NAMED.has(name) && !inParagraph)) ) { current = { name, text: head[2] ? [head[2]] : [], block: [] }; sections.push(current); } else if (isIndented(line)) { current ??= prose(); current.block.push(line); } else if (line.trim()) { if (!current || current.block.length) current = prose(); current.text.push(line.trim()); } else if (current?.block.length) current.block.push(""); else if (current && (!current.name || current.text.length)) current = undefined; } return { subject: lines[0], sections, trailers: lines.slice(end) }; } /** * AI assistants by the email their `Co-Authored-By:` trailers give, and the * short name drawn for them: the name in the trailer changes with the model * ("Claude Opus 5.5"), the email does not. */ const ASSISTANTS = new Map([["noreply@anthropic.com", "Claude"]]); /** * A commit's record: its identity and its message, parsed; the co-authors * are the names its `Co-Authored-By:` trailers give, an assistant's short. * @param {CommitInfo} commit * @returns {CommitRecord} */ export function parseRecord(commit) { const { subject, sections, trailers } = parseMessage(commit.message); const coauthors = trailers .filter(l => /^Co-Authored-By:/i.test(l)) .map(l => { const [, name, email = ""] = /^Co-Authored-By:\s*(.*?)\s*(?:<([^>]*)>)?\s*$/i.exec(l) ?? []; return ASSISTANTS.get(email.toLowerCase()) ?? name; }); return { ...commit, subject, sections, coauthors: [...new Set(coauthors)] }; } /** * Whether a record says why its commit is made, as the format requires: a * paragraph of prose or a `Why:` section with content. A subject with only * trailers, object blocks or code has no description. * @param {CommitRecord} r */ export const described = r => r.sections.some( s => (s.name === "" && s.text.length > 0) || (s.name === "Why" && (s.text.length > 0 || s.block.some(l => l.trim()))), ); /** * Who made a commit, as `A Person + Claude`: its author, by the GitHub login * when known, and its co-authors, each name drawn by `name` and the login * as a link to the profile by `profile` (a link, not an `@` mention, which * would notify the user). * @param {CommitRecord} r * @param {(name: string) => string} name * @param {(login: string, url: string) => string} profile */ export const byline = (r, name, profile) => [ r.login && /^[A-Za-z0-9-]+$/.test(r.login) ? profile(r.login, `https://github.com/${r.login}`) : name(r.author), ...r.coauthors.map(name), ].join(" + "); // --------------------------------------------------------------------------- // Code, highlighted by token /** * A language's tokens: a regular expression whose named groups are the * token classes, and its keywords among the identifiers. * @typedef {{ token: RegExp, keywords: Set }} Lang */ /** @param {string} s */ const words = s => new Set(s.split(" ")); /** @type {{ [L in "rescript" | "js" | "lambda"]: Lang }} */ const LANG = { rescript: { token: /(?\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(?"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`|'(?:\\u\{[0-9a-fA-F]+\}|\\.|[^'\\\n])')|(?@{1,2}[A-Za-z_][\w.]*|%{1,2}[A-Za-z_][\w.]*)|(?

#[A-Za-z_]\w*)|(?\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b|\b0[xob][0-9a-fA-F_]+\b)|(?'[a-z]\w*)|(?[A-Za-z_][\w']*)/g, keywords: words( "let rec and switch if else type module open include mutable async await for in to downto while external exception try catch when as private constraint assert dict list true false break continue of", ), }, js: { token: /(?\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(?"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|(?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b|\b0x[0-9a-fA-F]+\b)|(?[A-Za-z_$][\w$]*)/g, keywords: words( "let const var function return if else for while do new import export from as default this true false null undefined typeof instanceof class async await throw try catch finally of in switch case break continue void delete yield", ), }, lambda: { token: /(?"(?:\\.|[^"\\\n])*")|(?

#[A-Za-z_][\w.]*)|(?[A-Za-z_][\w$.']*)(?\/\d+)?|(?-?\b\d+(?:\.\d+)?[nl]?\b)|(?[()])/g, keywords: words( "let letrec function if switch stringswitch staticcatch staticraise catch exit try with while for seq apply makeblock field setfield default case", ), }, }; /** * Code highlighted by token: comments, strings, attributes and extensions, * polymorphic variants, numbers, keywords, capitalised names (modules, * constructors), type variables; in Lambda, primitives (`#is_not_none`), * identifier stamps (`x/1002`) and parentheses. * @param {string} code * @param {keyof typeof LANG} lang */ function highlight(code, lang) { const { token, keywords } = LANG[lang]; let out = ""; let last = 0; for (const m of code.matchAll(token)) { out += esc(code.slice(last, m.index)); last = (m.index ?? 0) + m[0].length; const g = m.groups ?? {}; const span = (/** @type {string} */ cls, /** @type {string} */ t) => t .split("\n") .map(part => `${esc(part)}`) .join("\n"); if (g.i !== undefined) { const id = g.i; out += keywords.has(id) ? span("k", id) : /^[A-Z]/.test(id) ? span("t", id) : esc(id); if (g.stamp) out += span("stamp", g.stamp); } else { const cls = ["c", "s", "a", "p", "n", "v", "paren"].find( k => g[k] !== undefined, ); out += cls ? span(cls, m[0]) : esc(m[0]); } } return out + esc(code.slice(last)); } /** * The pairs of line numbers a longest common subsequence of `a` and `b` * matches, in order, lines compared by `key`. * @param {string[]} a * @param {string[]} b * @param {(line: string) => string} key * @returns {[number, number][]} */ export function matchLines(a, b, key) { const x = a.map(key); const y = b.map(key); /** @type {number[][]} */ const t = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0), ); for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) t[i][j] = x[i] === y[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]); /** @type {[number, number][]} */ const pairs = []; for (let i = 0, j = 0; i < x.length && j < y.length; ) { if (x[i] === y[j]) pairs.push([i++, j++]); else if (t[i + 1][j] >= t[i][j + 1]) i++; else j++; } return pairs; } /** * The rows of a Before/After pair drawn side by side: a line common to both * versions is one row; between two common lines, the lines only Before has * and the lines only After has share rows, the shorter side padded with * gaps (`null`). Lines compare with their indentation, so a line that moves * to another depth is a change. * @param {string[]} a * @param {string[]} b * @returns {{ a: number | null, b: number | null, same: boolean }[]} */ export function alignLines(a, b) { /** @type {{ a: number | null, b: number | null, same: boolean }[]} */ const rows = []; let i = 0; let j = 0; const ends = [...matchLines(a, b, l => l.trimEnd()), [a.length, b.length]]; for (const [x, y] of ends) { for (let k = 0; k < Math.max(x - i, y - j); k++) rows.push({ a: i + k < x ? i + k : null, b: j + k < y ? j + k : null, same: false, }); if (x < a.length) rows.push({ a: x, b: y, same: true }); i = x + 1; j = y + 1; } return rows; } /** * The lines or parts of one version of a Before/After pair that the other * lacks, and their shading. * @typedef {{ marked?: Set, mark?: "add" | "del" }} Marks */ /** * One drawn line. A line too long for its column wraps, its continuation * indented two columns past `indent`, the column its content starts at. * @param {string} html * @param {number} indent * @param {string} [cls] * @param {string} [style] */ const line = (html, indent, cls = "", style = "") => `${html || " "}`; /** The column a line's content starts at. @param {string} l */ const indentOf = l => /^ */.exec(l)?.[0].length ?? 0; /** * Lines of code, each drawn by `draw`, as HTML, one string a line. * @param {string[]} lines * @param {(code: string) => string} draw */ const codeHtml = (lines, draw) => draw(lines.join("\n")).split("\n"); /** An elision line, `…` and any text, marking lines left out. @param {string} l */ export const elided = l => l.trimStart().startsWith("…"); /** * Lines of code, each drawn by `draw`, the marked ones shaded and the * elisions muted. * @param {string[]} lines * @param {(code: string) => string} draw * @param {Marks} [marks] */ function codeLines(lines, draw, marks = {}) { return `

${codeHtml(lines, draw)
    .map((l, i) =>
      line(
        l,
        indentOf(lines[i] ?? ""),
        elided(lines[i] ?? "")
          ? "elide"
          : marks.marked?.has(i)
            ? marks.mark
            : "",
      ),
    )
    .join("")}
`; } /** A note line of a drawing: `label: text`. */ export const NOTE = /^\s*[a-z][\w ]{0,20}:\s/; /** * The parts of a figure (a typing rule or a rewrite) that a Before/After * pair compares: each part's text and its slot, the place it holds in the * figure. Parts compare with the parts of the other version in their slot. * @typedef {{ slot: string, text: string }} Atom */ /** * The parts of `a` and of `b` outside a longest common subsequence of the * parts in each slot: the parts a Before/After pair of figures changes. * @param {Atom[]} a * @param {Atom[]} b * @returns {[Set, Set]} */ export function atomDiff(a, b) { const del = new Set(); const add = new Set(); for (const slot of new Set([...a, ...b].map(x => x.slot))) { const ia = a.flatMap((x, n) => (x.slot === slot ? [n] : [])); const ib = b.flatMap((x, n) => (x.slot === slot ? [n] : [])); const pairs = matchLines( ia.map(n => a[n].text), ib.map(n => b[n].text), t => t.trim(), ); const ka = new Set(pairs.map(p => p[0])); const kb = new Set(pairs.map(p => p[1])); for (const [k, n] of ia.entries()) if (!ka.has(k)) del.add(n); for (const [k, n] of ib.entries()) if (!kb.has(k)) add.add(n); } return [del, add]; } /** * A group of code lines: plain code, or a rewrite, its terms in order * (`a ⇝ b ⇝ c`) and its side conditions. * @typedef {{ plain: string[] } | { terms: string[][], conds: string[] }} CodeGroup */ /** * The groups of code separated by blank lines. In a group holding `⇝`, each * `⇝` separates the code before it from the code after it (`lhs ⇝ rhs` on * one line, or `⇝` alone on a line), and the `label: text` lines after the * last term are side conditions. * @param {string[]} lines * @returns {CodeGroup[]} */ function codeGroups(lines) { return groups(lines).map(ls => { const last = ls.map(l => l.includes("⇝")).lastIndexOf(true); if (last < 0) return { plain: ls }; const rest = ls.slice(last + 1); const cond = rest.findIndex(l => NOTE.test(l)); const body = [ ...ls.slice(0, last + 1), ...(cond < 0 ? rest : rest.slice(0, cond)), ]; /** @type {string[][]} */ const terms = [[]]; for (const l of body) l.split("⇝").forEach((piece, i) => { if (i > 0) terms.push([]); const t = i === 0 ? piece.trimEnd() : piece.trim(); if (t.trim()) terms[terms.length - 1].push(t); }); return { terms: terms.map(dedent), conds: cond < 0 ? [] : rest.slice(cond), }; }); } /** * The parts of code a Before/After pair compares: each line of plain code, * each line of a rewrite's term in the slot of the term's position, each * side condition. * @param {string[]} lines * @returns {Atom[]} */ const codeAtoms = lines => codeGroups(lines).flatMap(g => "plain" in g ? g.plain.map(text => ({ slot: "plain", text })) : [ ...g.terms.flatMap((t, i) => t.map(text => ({ slot: `term${i}`, text })), ), ...g.conds.map(text => ({ slot: "cond", text })), ], ); /** * Code, or rewrites of code (`codeGroups`): each term drawn as code, `⇝` * between, the side conditions drawn as mathematics. Marked parts * (numbered as `codeAtoms` lists them) are shaded. * @param {string[]} lines * @param {keyof typeof LANG} lang * @param {Marks} [marks] */ function code(lines, lang, marks = {}) { const draw = (/** @type {string} */ c) => highlight(c, lang); if (!lines.some(l => l.includes("⇝"))) return codeLines(lines, draw, marks); let n = 0; /** The marks of the next `count` parts, numbered from 0. @param {number} count */ const shade = count => { const from = n; n += count; return { marked: new Set( Array.from({ length: count }, (_, k) => k).filter(k => marks.marked?.has(from + k), ), ), mark: marks.mark, }; }; return `
${codeGroups(lines) .map(g => { if ("plain" in g) return codeLines(g.plain, draw, shade(g.plain.length)); const terms = g.terms .map(t => codeLines(t, draw, shade(t.length))) .join('⇝'); return ( `
${terms}
` + g.conds .map(l => condition(l, shade(1).marked.size ? (marks.mark ?? "") : ""), ) .join("") ); }) .join("")}
`; } /** * A side condition: `label: formula`, or a formula. * @param {string} l * @param {string} [mark] */ function condition(l, mark = "") { const m = /^\s*([a-z][\w ]{0,20}):\s+(.*)$/.exec(l); return `
${m ? `${esc(m[1])}` : ""}${math(m ? m[2] : l.trim())}
`; } // --------------------------------------------------------------------------- // Inline text /** Short shas of the cards on the page, for linking mentions. */ const pageShas = new Set(); /** * Prose: `code` as highlighted ReScript; `#1234` and full pull request and * issue URLs as `#1234` links; a 9-character short sha of a commit on the * page as a link to its card. * @param {string} s */ const inline = s => s .split(/(`[^`]+`)/) .map((part, i) => i % 2 ? `${highlight(part.slice(1, -1), "rescript")}` : esc(part) .replace( /https:\/\/github\.com\/rescript-lang\/rescript\/(pull|issues)\/(\d+)/g, `
#$2`, ) .replace( /(^|[\s(])#(\d{3,5})\b/g, `$1#$2`, ) .replace(/\b([0-9a-f]{9})\b/g, (m, sha) => pageShas.has(sha) ? `${sha}` : m, ), ) .join(""); // --------------------------------------------------------------------------- // Mathematics: inference rules and formulas /** * A formula's tokens: `code` in backticks, a sub- or superscript (`_x`, * `_{…}`, `^{…}`), a name, or any other character. * @param {string} text */ export const mathTokens = text => text.matchAll( /(?`[^`]+`)|(?