@{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(
/(?`[^`]+`)|(?