@pith-hash/pith-unicode 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1 +1,288 @@
1
- module.exports = {};
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 pith-hash
3
+ "use strict";
4
+
5
+ /**
6
+ * pith-unicode SDK: Unicode NFC/NFD normalisation through koffi.
7
+ *
8
+ * Every function validates its input as UTF-8 inside the Rust core and
9
+ * returns a fresh Buffer — the handed-out cdylib buffer is copied and
10
+ * released before returning. Invalid UTF-8 throws an `FfiError` with
11
+ * `status === -2`; it is never a crash.
12
+ */
13
+
14
+ const koffi = require("koffi");
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+
18
+ const STATUS_OK = 0;
19
+ const STATUS_INVALID = -1;
20
+ const STATUS_REJECTED = -2;
21
+
22
+ /** Normalization form codes (UAX #15), the FFI `form` argument encoding. */
23
+ const FORM_NFC = 1;
24
+ const FORM_NFD = 2;
25
+ const FORM_NFKC = 3;
26
+ const FORM_NFKD = 4;
27
+
28
+ /** Accepted spellings of a normalization form: the codes above or their names. */
29
+ const NORMALIZATION_FORMS = { NFC: FORM_NFC, NFD: FORM_NFD, NFKC: FORM_NFKC, NFKD: FORM_NFKD };
30
+
31
+ /** Every cdylib file name cargo may drop into the build directory, per platform. */
32
+ const CDYLIB_NAMES = ["pith_unicode.dll", "libpith_unicode.so", "libpith_unicode.dylib"];
33
+
34
+ const PKG_ROOT = path.join(__dirname);
35
+ const REPO_ROOT = path.resolve(__dirname, "..", "..");
36
+
37
+ /** Backs the data pointer for empty inputs: the FFI contract treats a
38
+ * NULL data pointer as a caller bug (status -1), so an empty Buffer
39
+ * must hand over a valid non-null pointer that reads as zero bytes. */
40
+ const EMPTY_SCRATCH = Buffer.alloc(1);
41
+
42
+ /** FfiError: a non-zero status code came back from the cdylib. */
43
+ class FfiError extends Error {
44
+ /**
45
+ * @param {string} op the FFI operation name
46
+ * @param {number} status the raw status code
47
+ */
48
+ constructor(op, status) {
49
+ const kind = { [STATUS_INVALID]: "invalid argument", [STATUS_REJECTED]: "input rejected" }[status] ?? "unknown failure";
50
+ super(`${op} failed: ${kind} (status ${status})`);
51
+ this.name = "FfiError";
52
+ /** The raw status code the FFI returned. */
53
+ this.status = status;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Locates the cdylib through the suite's discovery chain.
59
+ * @returns {string} an absolute path to the cdylib file
60
+ * @throws {Error} when nothing is found
61
+ */
62
+ function findCdylib() {
63
+ const explicit = process.env.PITH_CDYLIB;
64
+ if (explicit && fs.statSync(explicit, { throwIfNoEntry: false })?.isFile()) {
65
+ return path.resolve(explicit);
66
+ }
67
+ /** @type {string[]} */
68
+ const dirs = [];
69
+ const envDir = process.env.PITH_CDYLIB_DIR;
70
+ if (envDir) {
71
+ dirs.push(envDir);
72
+ if (!path.isAbsolute(envDir)) {
73
+ dirs.push(path.join(REPO_ROOT, envDir));
74
+ }
75
+ }
76
+ const osArch = `${process.platform}-${process.arch}`;
77
+ dirs.push(path.join(PKG_ROOT, "prebuilds", osArch));
78
+ dirs.push(path.join(PKG_ROOT, "prebuilds"));
79
+ dirs.push(path.join(REPO_ROOT, "target", "release"));
80
+ for (const dir of dirs) {
81
+ for (const name of CDYLIB_NAMES) {
82
+ const p = path.join(dir, name);
83
+ if (fs.statSync(p, { throwIfNoEntry: false })?.isFile()) return p;
84
+ }
85
+ }
86
+ throw new Error(
87
+ "no pith-unicode cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, prebuilds/ and <repo>/target/release); " +
88
+ "run `cargo build --release` first",
89
+ );
90
+ }
91
+
92
+ let cached = undefined;
93
+
94
+ /**
95
+ * Loads the cdylib and binds the exported symbols (lazily, once).
96
+ * Exported so the null-pointer refusal tests can drive the raw
97
+ * binding with a null data pointer.
98
+ *
99
+ * @returns {{nfc: Function, nfd: Function, free: Function}}
100
+ */
101
+ function loadLibrary() {
102
+ if (cached) return cached;
103
+ const lib = koffi.load(findCdylib());
104
+ // One shared prototype shape: nfc, nfd, nfkc, nfkd and both folds have
105
+ // identical C signatures.
106
+ const bind = (name) =>
107
+ lib.func(name, "int32_t", [
108
+ "const uint8_t *",
109
+ "size_t",
110
+ koffi.out(koffi.pointer("void *")),
111
+ koffi.out(koffi.pointer("size_t")),
112
+ ]);
113
+ const nfc = bind("pith_unicode_nfc");
114
+ const nfd = bind("pith_unicode_nfd");
115
+ const nfkc = bind("pith_unicode_nfkc");
116
+ const nfkd = bind("pith_unicode_nfkd");
117
+ const casefold = bind("pith_unicode_casefold");
118
+ const casefoldSimple = bind("pith_unicode_casefold_simple");
119
+ const free = lib.func("void pith_unicode_free(void *ptr, size_t len)");
120
+ const isNormalized = lib.func("pith_unicode_is_normalized", "int32_t", [
121
+ "uint32_t", // normalization form code
122
+ "const uint8_t *", // data
123
+ "size_t", // len
124
+ koffi.out(koffi.pointer("uint8_t")), // the 0/1 answer
125
+ ]);
126
+ cached = { nfc, nfd, nfkc, nfkd, casefold, casefoldSimple, free, isNormalized };
127
+ return cached;
128
+ }
129
+
130
+ /** Resolves a normalization form given as a code or name.
131
+ *
132
+ * @param {number|string} form the form code (FORM_NFC…) or name ("NFC"…)
133
+ * @returns {number} the FFI form code
134
+ */
135
+ function formCode(form) {
136
+ if (typeof form === "string") {
137
+ const code = NORMALIZATION_FORMS[form.toUpperCase()];
138
+ if (code === undefined) {
139
+ throw new TypeError(`unknown normalization form ${form}`);
140
+ }
141
+ return code;
142
+ }
143
+ if (Object.values(NORMALIZATION_FORMS).includes(form)) {
144
+ return form;
145
+ }
146
+ throw new TypeError(`unknown normalization form code ${form}`);
147
+ }
148
+
149
+ /** The loadLibrary() key of each form's buffer op. */
150
+ const FORM_KEYS = { [FORM_NFC]: "nfc", [FORM_NFD]: "nfd", [FORM_NFKC]: "nfkc", [FORM_NFKD]: "nfkd" };
151
+
152
+ /** Runs one buffer-handed-out op and copies the handed-out cdylib buffer
153
+ * into a fresh Buffer before releasing it.
154
+ *
155
+ * @param {string} key the loadLibrary() binding key (for errors)
156
+ * @param {Buffer} data the UTF-8 bytes to transform
157
+ * @returns {Buffer} the transformed UTF-8 bytes
158
+ * @throws {FfiError} with `status === -2` for bytes that are not valid UTF-8
159
+ */
160
+ function bufferOp(key, data) {
161
+ if (!Buffer.isBuffer(data)) {
162
+ throw new TypeError("data must be a Buffer");
163
+ }
164
+ const lib = loadLibrary();
165
+ const op = lib[key];
166
+ const out = [null];
167
+ const outLen = [0];
168
+ // koffi hands a NULL pointer for a zero-length Buffer, but the FFI
169
+ // contract treats NULL as a caller bug (status -1); empty input must
170
+ // read as zero bytes off a valid non-null pointer instead.
171
+ const ptr = data.length > 0 ? data : EMPTY_SCRATCH;
172
+ const status = op(ptr, data.length, out, outLen);
173
+ if (status !== STATUS_OK) {
174
+ throw new FfiError(key, status);
175
+ }
176
+ try {
177
+ // koffi.decode hands back a Uint8Array view over the external
178
+ // buffer; copy it into a Buffer before the cdylib buffer is freed.
179
+ return Buffer.from(koffi.decode(out[0], "uint8_t", Number(outLen[0])));
180
+ } finally {
181
+ lib.free(out[0], Number(outLen[0]));
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Normalizes `data` to Unicode Normalization Form C (canonical
187
+ * composition) and returns the UTF-8 result as a fresh Buffer.
188
+ *
189
+ * @param {Buffer} data the UTF-8 bytes to normalize
190
+ * @returns {Buffer} the NFC-normalized bytes (empty in → empty out)
191
+ * @throws {FfiError} with `status === -2` for invalid UTF-8
192
+ */
193
+ function nfc(data) {
194
+ return bufferOp("nfc", data);
195
+ }
196
+
197
+ /**
198
+ * Normalizes `data` to Unicode Normalization Form D (canonical
199
+ * decomposition), with the same contract as `nfc`.
200
+ */
201
+ function nfd(data) {
202
+ return bufferOp("nfd", data);
203
+ }
204
+
205
+ /**
206
+ * Normalizes `data` to Unicode Normalization Form KC (compatibility
207
+ * composition), with the same contract as `nfc`.
208
+ */
209
+ function nfkc(data) {
210
+ return bufferOp("nfkc", data);
211
+ }
212
+
213
+ /**
214
+ * Normalizes `data` to Unicode Normalization Form KD (compatibility
215
+ * decomposition), with the same contract as `nfc`.
216
+ */
217
+ function nfkd(data) {
218
+ return bufferOp("nfkd", data);
219
+ }
220
+
221
+ /**
222
+ * Normalizes `data` to `form` — a NORMALIZATION_FORMS name ("NFC", "NFD",
223
+ * "NFKC", "NFKD", any case) or one of the FORM_NFC…FORM_NFKD codes — with
224
+ * the same contract as `nfc`.
225
+ */
226
+ function normalize(form, data) {
227
+ return bufferOp(FORM_KEYS[formCode(form)], data);
228
+ }
229
+
230
+ /**
231
+ * Answers whether `data` is already in normalization `form` — the exact
232
+ * UAX #15 quick-check answer, never a pessimistic approximation.
233
+ *
234
+ * @throws {TypeError} for an unknown form
235
+ * @throws {FfiError} with `status === -2` for invalid UTF-8
236
+ */
237
+ function isNormalized(form, data) {
238
+ if (!Buffer.isBuffer(data)) {
239
+ throw new TypeError("data must be a Buffer");
240
+ }
241
+ const lib = loadLibrary();
242
+ const out = [0xff];
243
+ const status = lib.isNormalized(formCode(form), data.length > 0 ? data : EMPTY_SCRATCH, data.length, out);
244
+ if (status !== STATUS_OK) {
245
+ throw new FfiError("pith_unicode_is_normalized", status);
246
+ }
247
+ return out[0] === 1;
248
+ }
249
+
250
+ /**
251
+ * Folds `data` to its full case folding (UAX #44 statuses `C` and `F`;
252
+ * mappings may expand, e.g. ß → ss). The Turkic `T` entries are locale
253
+ * data and are not applied. Same contract as `nfc`.
254
+ */
255
+ function casefold(data) {
256
+ return bufferOp("casefold", data);
257
+ }
258
+
259
+ /**
260
+ * Folds `data` to its simple case folding (statuses `C` and `S`): a
261
+ * strict one-to-one mapping (ẞ → ß, ß → itself).
262
+ */
263
+ function casefoldSimple(data) {
264
+ return bufferOp("casefoldSimple", data);
265
+ }
266
+
267
+ module.exports = {
268
+ STATUS_OK,
269
+ STATUS_INVALID,
270
+ STATUS_REJECTED,
271
+ CDYLIB_NAMES,
272
+ FORM_NFC,
273
+ FORM_NFD,
274
+ FORM_NFKC,
275
+ FORM_NFKD,
276
+ NORMALIZATION_FORMS,
277
+ FfiError,
278
+ findCdylib,
279
+ loadLibrary,
280
+ nfc,
281
+ nfd,
282
+ nfkc,
283
+ nfkd,
284
+ normalize,
285
+ isNormalized,
286
+ casefold,
287
+ casefoldSimple,
288
+ };
package/package.json CHANGED
@@ -1,14 +1,33 @@
1
- {
2
- "name": "@pith-hash/pith-unicode",
3
- "version": "0.0.0",
4
- "description": "pith SDK placeholder - real artifacts published by CI",
5
- "license": "MIT",
6
- "private": false,
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/pith-hash/pith-unicode.git"
13
- }
14
- }
1
+ {
2
+ "name": "@pith-hash/pith-unicode",
3
+ "version": "0.1.0",
4
+ "description": "Unicode NFC/NFD normalisation SDK via the pith-unicode Rust cdylib (koffi, prebuilt)",
5
+ "license": "MIT",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "prebuilds/",
10
+ "reference.json"
11
+ ],
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "dependencies": {
16
+ "koffi": "^2.12.0"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/pith-hash/pith-unicode.git",
24
+ "directory": "sdk/node"
25
+ },
26
+ "keywords": [
27
+ "unicode",
28
+ "nfc",
29
+ "nfd",
30
+ "normalization",
31
+ "hashing"
32
+ ]
33
+ }