#!/usr/bin/env node // Root-locale collation elements for String.prototype.localeCompare over the // Latin repertoire (ASCII, Latin-1 Supplement, Latin Extended-A and the // combining diacritical marks), derived from the pinned Node release's ICU // (`localeCompare` with no locales/options) and verified against it. // // node scripts/gen-collation-table.mjs regenerate the table // node scripts/gen-collation-table.mjs --check verify only // // Model: every covered code point maps to a short list of collation // elements (primary, secondary, tertiary). Precomposed letters are their // base letter's element followed by one secondary-only element for the // diacritic; letters with a stroke or other built-in variant (ø, ł, đ, ...) // carry the same extra secondary; ligatures expand (æ → a, e). Strings // compare level by level over the concatenated elements — primaries // (zero primaries skipped), then secondaries, then tertiaries — which is the // multi-level comparison ICU applies at its default (tertiary) strength. // The output is written only after a randomized differential check against // Node's own localeCompare passes. import { writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; const root = fileURLToPath(new URL("..", import.meta.url)); const out = join(root, "packages/runtime/src/scr_collation_data.h"); const checkOnly = process.argv.includes("--check"); const base = new Intl.Collator(undefined, { sensitivity: "base" }); const accent = new Intl.Collator(undefined, { sensitivity: "accent" }); const variant = new Intl.Collator(undefined, { sensitivity: "variant" }); export const RANGES = [ [0x09, 0x0d], [0x20, 0x7e], [0xa0, 0x17f], [0x300, 0x36f], ]; const ch = (c) => String.fromCodePoint(c); // Combining marks are covered when they are pure diacritics (no primary // weight); the combining Latin letters (U+0363–U+036F) are not. const isMark = (c) => c >= 0x300 && c <= 0x36f; // The vulgar fractions expand through U+2044 FRACTION SLASH, which is // outside the table, so they stay uncovered. const UNCOVERED = new Set([0xbc, 0xbd, 0xbe]); const covered = []; for (const [lo, hi] of RANGES) for (let c = lo; c <= hi; c++) if (!UNCOVERED.has(c) && (!isMark(c) || base.compare("a" + ch(c), "a") === 0)) covered.push(c); const coveredSet = new Set(covered); // 1. Combining marks: secondary weights by their order on a base letter. const marks = covered.filter(isMark); const markOrder = [...marks].sort((a, b) => accent.compare("a" + ch(a), "a" + ch(b)) || a - b); const markWeight = new Map(); { let w = 1; for (let i = 0; i < markOrder.length; i++) { if (i > 0 && accent.compare("a" + ch(markOrder[i - 1]), "a" + ch(markOrder[i])) !== 0) w++; markWeight.set(markOrder[i], w); } } // Canonical combining classes, as ranks: ICU normalizes (NFD, with marks // reordered by class) before collating. Derived from the canonical // reordering Node's normalize() applies to mark pairs. const reorders = (a, b) => ("x" + ch(a) + ch(b)).normalize("NFD") !== "x" + ch(a) + ch(b); const atomicMarks = marks.filter((m) => ch(m).normalize("NFD") === ch(m)); const movable = atomicMarks.filter((m) => atomicMarks.some((n) => reorders(m, n) || reorders(n, m)), ); const cccOrder = [...movable].sort( (a, b) => (reorders(a, b) ? 1 : reorders(b, a) ? -1 : 0) || a - b, ); const cccRank = new Map(); { let r = 1; for (let i = 0; i < cccOrder.length; i++) { const prev = cccOrder[i - 1]; if (i > 0 && (reorders(prev, cccOrder[i]) || reorders(cccOrder[i], prev))) r++; cccRank.set(cccOrder[i], r); } } // 2. Ignorable code points (no weight at any level). const ignorable = new Set( covered.filter( (c) => variant.compare(ch(c) + "a", "a") === 0 && variant.compare("a" + ch(c), "a") === 0, ), ); // 3. Expansions: a code point equal at the base level to two ASCII letters. const letters = "abcdefghijklmnopqrstuvwxyz"; const expansion = new Map(); for (const c of covered) { if (c < 0x80 || isMark(c) || ignorable.has(c) || ch(c).normalize("NFD") !== ch(c)) continue; if (covered.some((d) => d < 0x80 && base.compare(ch(c), ch(d)) === 0)) continue; for (const x of letters) for (const y of letters) if (base.compare(ch(c), x + y) === 0 && !expansion.has(c)) expansion.set(c, x + y); } // 4. Primary classes over single code points (base level), in order. const singles = covered.filter((c) => !isMark(c) && !ignorable.has(c) && !expansion.has(c)); const decomposedBase = (c) => { const nfd = ch(c).normalize("NFD"); return nfd.length > 1 && isMark(nfd.codePointAt(1)) ? nfd.codePointAt(0) : null; }; const primaryReps = singles.filter((c) => decomposedBase(c) === null); primaryReps.sort((a, b) => base.compare(ch(a), ch(b)) || a - b); const primaryOf = new Map(); { let p = 1; for (let i = 0; i < primaryReps.length; i++) { if (i > 0 && base.compare(ch(primaryReps[i - 1]), ch(primaryReps[i])) !== 0) p++; primaryOf.set(primaryReps[i], p); } } // The class representative: the ASCII member when there is one. const repOf = (c) => { const p = primaryOf.get(c); const ascii = primaryReps.find((d) => primaryOf.get(d) === p && d < 0x80); return ascii ?? primaryReps.find((d) => primaryOf.get(d) === p); }; // 5. Extra secondary of a non-decomposable variant letter (ø against o): // the mark whose sequence it equals at the accent level, or a weight // placed among the marks by comparison. const extraSecondary = new Map(); const variantMark = new Map(); let nextUnique = markOrder.length + 10; const syntheticAt = []; for (const c of primaryReps) { const r = repOf(c); if (r === c || accent.compare(ch(c), ch(r)) === 0) continue; const same = markOrder.find((m) => accent.compare(ch(c), ch(r) + ch(m)) === 0); if (same !== undefined) { variantMark.set(c, same); continue; } // Insert between mark weights: below the first mark it sorts before. let below = markOrder.findIndex((m) => accent.compare(ch(c), ch(r) + ch(m)) < 0); if (below < 0) below = markOrder.length; syntheticAt.push({ c, below }); } // Ligatures differ from their spelled-out pair by a secondary between the // halves (æ against a, e): a mark's weight or a synthetic one. const expansionSecondary = new Map(); const expansionMark = new Map(); for (const [c, pair] of expansion) { if (accent.compare(ch(c), pair) === 0) continue; const same = markOrder.find((m) => accent.compare(ch(c), pair[0] + ch(m) + pair[1]) === 0); if (same !== undefined) { expansionMark.set(c, same); continue; } let below = markOrder.findIndex((m) => accent.compare(ch(c), pair[0] + ch(m) + pair[1]) < 0); if (below < 0) below = markOrder.length; syntheticAt.push({ c, below, expansion: true }); } // Synthetic weights are fractional slots; renumber all secondaries densely. { const entries = []; for (const [m, w] of markWeight) entries.push({ key: `m${m}`, w: w * 2 }); // Case pairs (Æ/æ, Ð/ð) share their synthetic secondary: they differ at // the tertiary level only. const uniqueByFold = new Map(); for (const { c, below, expansion: isExpansion } of syntheticAt) { const fold = ch(c).toLowerCase(); if (below >= markOrder.length && !uniqueByFold.has(fold)) uniqueByFold.set(fold, nextUnique++); const w = below < markOrder.length ? markWeight.get(markOrder[below]) * 2 - 1 : uniqueByFold.get(fold) * 2; entries.push({ key: `${isExpansion ? "x" : "c"}${c}`, w }); } const distinct = [...new Set(entries.map((e) => e.w))].sort((a, b) => a - b); const dense = new Map(distinct.map((w, i) => [w, i + 2])); // 1 is "common" for (const e of entries) { const w = dense.get(e.w); if (e.key.startsWith("m")) markWeight.set(Number(e.key.slice(1)), w); else if (e.key.startsWith("x")) expansionSecondary.set(Number(e.key.slice(1)), w); else extraSecondary.set(Number(e.key.slice(1)), w); } } for (const [c, m] of variantMark) extraSecondary.set(c, markWeight.get(m)); for (const [c, m] of expansionMark) expansionSecondary.set(c, markWeight.get(m)); // 6. Elements per code point (tertiary assigned below). const COMMON = 1; const elements = new Map(); for (const c of covered) { if (ignorable.has(c)) elements.set(c, []); else if (isMark(c) && ch(c).normalize("NFD") === ch(c) && markWeight.has(c)) elements.set(c, [[0, markWeight.get(c), COMMON, cccRank.get(c) ?? 0]]); } for (const c of primaryReps) { const ces = [[primaryOf.get(c), COMMON, 0, 0]]; if (extraSecondary.has(c)) ces.push([0, extraSecondary.get(c), COMMON, 0]); elements.set(c, ces); } for (const c of covered) { if (elements.has(c) || expansion.has(c)) continue; // Canonically decomposable: the elements of its NFD atoms. const nfd = [...ch(c).normalize("NFD")].map((x) => x.codePointAt(0)); if (nfd.length === 1 && nfd[0] === c) throw new Error(`no elements for U+${c.toString(16)}`); elements.set( c, nfd.flatMap((x) => { const es = elements.get(x); if (es === undefined) throw new Error(`U+${c.toString(16)} decomposes outside the table`); return es.map((e) => [...e]); }), ); } for (const [c, pair] of expansion) { const first = elements.get(pair.codePointAt(0)).map((e) => [...e]); const second = elements.get(pair.codePointAt(1)).map((e) => [...e]); // A ligature differs from its spelled-out pair at the secondary level: // find the mark-equivalent secondary between the two halves, if any. const middle = expansionSecondary.has(c) ? [[0, expansionSecondary.get(c), COMMON, 0]] : []; elements.set(c, [...first, ...middle, ...second]); } // 7. Tertiary: rank code points whose primary+secondary elements agree. const psKey = (c) => elements .get(c) .map((e) => `${e[0]}.${e[1]}`) .join(" "); const groups = new Map(); for (const c of covered) { if (ignorable.has(c) || isMark(c)) continue; const k = psKey(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); } const tertiaryOf = new Map(); for (const members of groups.values()) { members.sort((a, b) => variant.compare(ch(a), ch(b)) || a - b); let t = 1; for (let i = 0; i < members.length; i++) { if (i > 0 && variant.compare(ch(members[i - 1]), ch(members[i])) !== 0) t++; tertiaryOf.set(members[i], t); } } for (const c of covered) { if (ignorable.has(c) || isMark(c)) continue; for (const e of elements.get(c)) if (e[0] !== 0) e[2] = tertiaryOf.get(c); } // The model's comparison (mirrored by scr_str_locale_compare in C). export function modelCompare(a, b) { const ces = (s) => { const es = [...s].flatMap((x) => elements.get(x.codePointAt(0))); // Canonical reordering: stable sort each run of movable marks by class. for (let i = 1; i < es.length; i++) { const e = es[i]; if (e[3] === 0) continue; let j = i; while (j > 0 && es[j - 1][3] > e[3]) { es[j] = es[j - 1]; j--; } es[j] = e; } return es; }; const ea = ces(a); const eb = ces(b); for (const level of [0, 1, 2]) { // Zero primaries (diacritics) are skipped at the primary level only. const wa = ea.filter((e) => level !== 0 || e[0] !== 0).map((e) => e[level]); const wb = eb.filter((e) => level !== 0 || e[0] !== 0).map((e) => e[level]); const n = Math.min(wa.length, wb.length); for (let i = 0; i < n; i++) if (wa[i] !== wb[i]) return wa[i] < wb[i] ? -1 : 1; if (wa.length !== wb.length) return wa.length < wb.length ? -1 : 1; } return 0; } // 8. Differential verification against Node. let seed = 0x5eed; const rnd = (n) => { seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; return seed % n; }; const pool = covered.filter((c) => !isMark(c)); const heavy = [..."aAbBeEoOsSzZ ,.-_'1 2éÉèäÄöøØßæÆœłŁđ"].map((x) => x.codePointAt(0)); const word = () => { const n = 1 + rnd(5); let s = ""; for (let i = 0; i < n; i++) { const r = rnd(10); s += ch( r < 6 ? heavy[rnd(heavy.length)] : r < 9 ? pool[rnd(pool.length)] : marks[rnd(marks.length)], ); } return s; }; let mismatches = 0; const samples = []; const check = (a, b) => { const want = a.localeCompare(b); const got = modelCompare(a, b); if (Math.sign(want) !== got) { mismatches++; if (samples.length < 20) samples.push(`${JSON.stringify(a)} vs ${JSON.stringify(b)}: node ${want}, model ${got}`); } }; for (const x of pool) for (const y of pool) check(ch(x), ch(y)); for (let i = 0; i < 300000; i++) check(word(), word()); if (mismatches > 0) { console.error(`collation model disagrees with Node in ${mismatches} cases:`); for (const s of samples) console.error(` ${s}`); process.exit(1); } console.log(`collation model matches Node over ${pool.length ** 2 + 300000} comparisons`); if (checkOnly) process.exit(0); // 9. Emit the C table. const lines = []; lines.push("/* Generated by scripts/gen-collation-table.mjs from the pinned Node"); lines.push(" * release's root-locale collation (ICU, localeCompare without locales or"); lines.push(" * options). Do not edit by hand. Each covered code point maps to up to"); lines.push(" * three collation elements {primary, secondary, tertiary, combining class"); lines.push(" * rank}; zero primaries are skipped at the primary level, and runs of"); lines.push(" * marks with a nonzero class rank are put in canonical order before"); lines.push(" * comparing. n == 255 marks an uncovered code point. */"); lines.push("#ifndef SCR_COLLATION_DATA_H"); lines.push("#define SCR_COLLATION_DATA_H"); lines.push("#include "); lines.push("typedef struct { uint16_t p; uint8_t s; uint8_t t; uint8_t ccc; } ScrCollElem;"); lines.push("typedef struct { uint8_t n; ScrCollElem e[3]; } ScrCollEntry;"); const maxP = Math.max(...[...elements.values()].flat().map((e) => e[0])); const maxS = Math.max(...[...elements.values()].flat().map((e) => e[1])); const maxT = Math.max(...[...elements.values()].flat().map((e) => e[2])); if (maxP > 0xffff || maxS > 0xff || maxT > 0xff) throw new Error("weights overflow the table types"); for (const [lo, hi] of RANGES) { const name = `scr_coll_${lo.toString(16).padStart(4, "0")}`; lines.push(`static const ScrCollEntry ${name}[${hi - lo + 1}] = {`); for (let c = lo; c <= hi; c++) { if (!coveredSet.has(c)) { lines.push( ` {255, {{0, 0, 0, 0}}}, /* U+${c.toString(16).toUpperCase().padStart(4, "0")} not covered */`, ); continue; } const es = elements.get(c); if (es.length > 3) throw new Error(`U+${c.toString(16)} needs ${es.length} elements`); const body = es.map((e) => `{${e[0]}, ${e[1]}, ${e[2]}, ${e[3]}}`).join(", "); lines.push( ` {${es.length}, {${body}}}, /* U+${c.toString(16).toUpperCase().padStart(4, "0")} */`, ); } lines.push("};"); } lines.push("static const ScrCollEntry *scr_coll_lookup(uint32_t c) {"); for (const [lo, hi] of RANGES) { const name = `scr_coll_${lo.toString(16).padStart(4, "0")}`; lines.push( ` if (c >= 0x${lo.toString(16)} && c <= 0x${hi.toString(16)}) return &${name}[c - 0x${lo.toString(16)}];`, ); } lines.push(" return 0;"); lines.push("}"); lines.push("#endif"); await writeFile(out, lines.join("\n") + "\n"); console.log(`wrote ${out}`);