/** * Intl.Segmenter fallback for small-ICU Node builds * ───────────────────────────────────────────────── * On Node built with small-ICU (e.g. the RHEL/Fedora `nodejs` RPM without * `nodejs-full-i18n`), `new Intl.Segmenter()` succeeds but the first * `.segment()` call dereferences a null icu::BreakIterator inside V8 and * segfaults the whole process. pi-tui calls `.segment()` for grapheme-cluster * cursor/width math, so pi's TUI dies at startup on those builds. * * Upstream: https://github.com/nodejs/node/issues/51752 * pi issue: https://github.com/earendil-works/pi/issues/6359 * * This extension detects the broken build (`process.config.variables * .icu_small === true`) and, at import time, replaces * `Intl.Segmenter.prototype.segment` (and `resolvedOptions`) with a pure-JS * fallback. Patching the *prototype* — rather than the constructor — means it * also covers segmenter instances that pi-tui already created at module load, * so the extension only has to load before the first `.segment()` CALL (the * first TUI render), which pi's extension loader guarantees. * * On healthy (full-ICU / system-ICU) builds this file is a strict no-op. * * Fallback semantics (deliberately simple, good enough for a terminal UI): * - "grapheme": one segment per Unicode code point (`for..of` iteration — * surrogate-pair safe). Multi-code-point clusters (ZWJ emoji, skin-tone * modifiers, combining marks) split into their parts; cursor math stays * consistent because pi-tui derives widths from the same segmentation. * - "word": runs of word-ish code points (`\p{L}\p{N}\p{M}\p{Pc}`, tagged * isWordLike: true), runs of whitespace, and individual other characters. * - "sentence": naive split after terminal punctuation (unused by pi). * * Env overrides (mostly for testing): * PI_SEGMENTER_FALLBACK=1 force the patch on even with healthy ICU * PI_SEGMENTER_FALLBACK=0 never patch, even on small-ICU builds * * Zero runtime dependencies. Install: `pi install npm:pi-intl-segmenter-fallback` */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; type Granularity = "grapheme" | "word" | "sentence"; interface SegmentDatum { segment: string; index: number; input: string; isWordLike?: boolean; } const PATCH_FLAG = "__piSmallIcuSegmenterFallback"; const envOverride = process.env.PI_SEGMENTER_FALLBACK; const smallIcu = (process.config?.variables as { icu_small?: boolean } | undefined)?.icu_small === true; const shouldPatch = envOverride === "1" || (envOverride !== "0" && smallIcu); const patched = shouldPatch ? installFallback() : false; function installFallback(): boolean { const Segmenter = (Intl as { Segmenter?: { prototype: any } }).Segmenter; if (!Segmenter) return false; // no Segmenter at all — nothing to crash, nothing to patch const proto = Segmenter.prototype; if (proto.segment?.[PATCH_FLAG]) return true; // idempotent across /reload // resolvedOptions only reads internal slots (locale/granularity strings); // it never touches the broken BreakIterator, so calling the native one is // safe even on small-ICU builds. We need it once per instance to learn the // granularity of segmenters constructed before this extension loaded // (pi-tui's module-level singletons). It also doubles as the brand check: // it throws TypeError when `this` is not a real Intl.Segmenter. const nativeResolvedOptions = proto.resolvedOptions; const optionsCache = new WeakMap(); function resolveOptions(self: object): { locale: string; granularity: Granularity } { let opts = optionsCache.get(self); if (!opts) { const r = nativeResolvedOptions.call(self); opts = { locale: r.locale, granularity: r.granularity }; optionsCache.set(self, opts); } return opts; } function segment(this: object, input: unknown) { const { granularity } = resolveOptions(this); return makeSegments(String(input), granularity); } (segment as any)[PATCH_FLAG] = true; function resolvedOptions(this: object) { return { ...resolveOptions(this) }; } (resolvedOptions as any)[PATCH_FLAG] = true; // Plain assignment: both properties already exist on the prototype as // writable data properties, so their non-enumerable attribute is kept. proto.segment = segment; proto.resolvedOptions = resolvedOptions; return true; } /** Build a Segments-like object: iterable of {segment,index,input[,isWordLike]} plus containing(). */ function makeSegments(input: string, granularity: Granularity) { const data = segmentize(input, granularity); return { containing(index: number): SegmentDatum | undefined { const i = Math.trunc(Number(index)); if (!(i >= 0) || i >= input.length) return undefined; for (const d of data) { if (i < d.index + d.segment.length) return { ...d }; } return undefined; }, [Symbol.iterator](): IterableIterator { let k = 0; const iter: IterableIterator = { next: () => (k < data.length ? { value: { ...data[k++] }, done: false } : { value: undefined as any, done: true }), [Symbol.iterator]: () => iter, }; return iter; }, }; } const WORD_CP = /[\p{L}\p{N}\p{M}\p{Pc}]/u; const SPACE_CP = /\s/u; // A "sentence": anything up to (and including) a run of terminal punctuation // plus trailing closers/whitespace, or the unterminated tail of the string. const SENTENCE_RE = /[^.!?…]+(?:[.!?…]+["')\]]*\s*)?|[.!?…]+["')\]]*\s*/gu; function segmentize(input: string, granularity: Granularity): SegmentDatum[] { const out: SegmentDatum[] = []; if (input.length === 0) return out; if (granularity === "word") { let pos = 0; let runStart = 0; let runType: "word" | "space" | null = null; const flushRun = (end: number) => { if (runType !== null && end > runStart) { out.push({ segment: input.slice(runStart, end), index: runStart, input, isWordLike: runType === "word" }); } runType = null; }; for (const cp of input) { const type = WORD_CP.test(cp) ? "word" : SPACE_CP.test(cp) ? "space" : "other"; if (type === "other") { flushRun(pos); out.push({ segment: cp, index: pos, input, isWordLike: false }); runStart = pos + cp.length; } else if (type !== runType) { flushRun(pos); runType = type; runStart = pos; } pos += cp.length; } flushRun(input.length); } else if (granularity === "sentence") { for (const m of input.matchAll(SENTENCE_RE)) { if (m[0]) out.push({ segment: m[0], index: m.index!, input }); } } else { // "grapheme" (and any unknown granularity): one segment per code point. let pos = 0; for (const cp of input) { out.push({ segment: cp, index: pos, input }); pos += cp.length; } } return out; } export default function (pi: ExtensionAPI) { // Nothing to report on healthy builds, or when the patch was forced on for testing. if (!patched || envOverride === "1") return; const message = "Small-ICU Node build detected: Intl.Segmenter would segfault (nodejs/node#51752), " + "using a pure-JS code-point fallback. Multi-code-point emoji may take extra cursor steps. " + "Real fix: install your distro's nodejs-full-i18n package."; let warned = false; pi.on("session_start", async (_event, ctx) => { if (warned) return; // session_start also fires on reload/switch — warn once per process warned = true; if (ctx.hasUI) { ctx.ui.notify(`[pi-intl-segmenter-fallback] ${message}`, "warning"); } else { console.warn(`[pi-intl-segmenter-fallback] ${message}`); } }); }