@slnknrr/str-im
v1.0.0
Published
Zero-allocation string engine: unique-character iterators, UTF-8 buffer inspection without decoding, run detection, Unicode classification. Numbers in the core, strings only at the boundary.
Maintainers
Readme
str-im
A zero-allocation string engine. Numbers in the core, strings only at the boundary.
str-im adds exactly the string operations JavaScript is missing — and nothing it already has. It never re-implements for..of, indexOf, or slice. It implements the things you keep hand-writing (with bugs): unique-character iterators, utf-8 buffer inspection without decoding, run detection, grapheme/width segmentation, mixed-script and homoglyph detection, bounded edit distance, single-pass multi-pattern search, lazy split, and entropy.
The core runs on code units and code points as numbers. A number below 2³¹ is a V8 Smi — it lives inside the pointer, so it costs zero allocations. Strings are created in exactly one place: the public boundary, when you ask for characters back. If you only ask a question ("how many distinct?", "is it mixed-script?", "does it fit in 120 bytes?"), no string is ever built.
import s from '@slnknrr/str-im';
s.ulen('mississippi'); // 4 — distinct chars, no strings allocated
[...s.char('mississippi')]; // ['m','i','s','p'] — unique, first-seen order
s.blen('héllo 👾'); // 11 — utf-8 byte length, computed WITHOUT encoding
s.bcut('hi 👾!', 7); // 'hi 👾' — cut to 7 utf-8 bytes; keeps the whole emoji, never half
s.mix('раypal'); // 1 — Latin + Cyrillic look-alikes: spoofing detected
s.near('kitten', 'sitting', 2); // Infinity — "not within 2 edits", answered without the full matrixWhat it is
- A precision tool for text the standard library fumbles. utf-8 bytes, grapheme clusters, display width, scripts, confusables, edit distance, run-length structure, n-grams, entropy.
- Lazy and bounded by default. Iterators stop when you stop pulling.
maxlets you ask "is there more than N?" and leave.nearanswers "≤ max edits?" inO(n·max)time andO(max)space — never the fullO(n·m). - Source-agnostic. Every method works on a
string, a utf-8Uint8Array/Buffer, or anArray<number>of code points — through a one-line adapter you can extend to your own type. - Regex-free classification. Not one character class is tested with a regex (
/\p{L}/u.test(c)forces a string allocation per character). Unicode properties are sortedInt32Arrayranges, generated offline, queried by binary search, and materialized lazily on first use. - Pure ESM, dependency-free, sync. ~1900 lines, one file, immutable tables. Node ≥ 20.
What it is NOT
- Not a faster
String/RegExp. It does not try to out-run V8's compiled regex orSetat eager, single-pattern, whole-string work — that is a C++ engine and it wins, deservedly.str-imwins where the native idiom is forced to do work you didn't ask for. See Performance for the exact line. - Not a normalizer or transliterator.
deburr/skelare for search and matching, not display:ö → odestroys meaning in German. Normalization isString.prototype.normalize;str-imcalls it, it doesn't replace it. - Not a full TR39 confusables database.
skelis NFKD + a built-in cross-script homoglyph minimum + whatever you add viastrim.confuse(). It catches the classicа/a,о/o, fullwidth, and ligature spoofs; it is not the entire Unicode confusables file. - Not async, not streaming I/O. It operates on in-memory sources. There are no promises anywhere.
- Not mutating. Inputs are never modified. You get a new string, a lazy iterator, or a number.
Install
npm install @slnknrr/str-imimport s from '@slnknrr/str-im'; // ready-to-use singleton — no `new`
import { strim, _strim, NotFound, UNICODE } from '@slnknrr/str-im';Requirements: Node ≥ 20, ESM only ("type": "module" or import). Tables track Unicode UNICODE (currently 17.0).
Core conventions
These are load-bearing. Learn them once; they apply everywhere.
Predicates return 0 | 1 | -1, not a boolean. 0 = no, 1 = yes, -1 = matched in full. -1 is truthy, so if (s.contains(a, b)) reads naturally — but "contains" and "equals" stay distinguishable without a second call.
s.starts('hello', 'he'); // 1
s.starts('hello', 'hello'); // -1 (equal, still truthy)
s.starts('hello', 'x'); // 0Validation is synchronous. A generator's body doesn't run until the first next(), so a naive lazy API would throw later, in the consumer's stack. str-im primes every iterator: all argument checks happen at the call site. Bad input throws when you call, exactly like an ordinary function.
s.char('x', 0); // throws RangeError NOW, not on first iterationErrors are typed. TypeError = wrong type. RangeError = index/length out of range. NotFound = a lookup target genuinely absent (its own class, so a missing substring is catchable apart from a real bug).
Saturating counts return Infinity. When a count is capped (consc(str, max)), reaching the cap yields Infinity — an honest "at least this many", never confused with a real result that happens to equal the cap.
The *char family yields each character at most once, in order of first appearance. That is the entire point: iterating all characters is what for..of and str[i] already do.
Sources & adapters
Every reading method resolves an adapter once per call, then stays in a tight numeric loop. Three sources work out of the box:
| Source | Decoding | Example |
|---|---|---|
| string | utf-16 code units | s.len('café') |
| Uint8Array / Buffer | utf-8 bytes | s.len(Buffer.from('café')) |
| Array<number> | ready code points | s.len([99, 97, 102, 233]) |
Register your own once, process-wide, and the entire method family works with it:
strim.use(MyRope, {
size: (r) => r.length, // number of units
at: (r, i) => r.codeUnitAt(i), // unit at i, as a number
dec: 0, // 0 = utf-16, 1 = utf-8 bytes, 2 = code points
});Performance
str-im is fast where laziness, bounds, single-pass multi-pattern, and not materializing matter. It does not compete with compiled regex on regex's turf, and says so out loud.
Representative run (npm run bench, Node 24.16, one machine, median of 3 — your numbers will differ; the shape won't):
| Case | str-im | the native way | speedup |
|---|--:|---|--:|
| "more than 8 distinct chars?" | 1.2M/s | [...new Set(s)] | ~1200× |
| first line of a big blob | 1.2M/s | s.split('\n')[0] | ~305× |
| truncate to 120 utf-8 bytes | 240k/s | encode + slice + repair | ~27× |
| find any of 400 needles (1 pass) | 850/s | 400× indexOf | ~2.4× |
| "edit distance ≤ 2?" | 510k/s | full DP matrix | ~1.9× |
| grapheme count | 4k/s | Intl.Segmenter | ~1.0× (par) |
Why these win, structurally: the early-exit iterator stops at 8 instead of scanning the string; lazy split yields the first piece instead of allocating the whole array; bcut streams the first ~120 bytes instead of encoding all 44 KB; Aho-Corasick scans once with cost flat in the needle count while indexOf pays one full scan per needle; banded near fills a diagonal stripe instead of the whole matrix. None of these is a micro-optimization — each is the native idiom being asked to do work you didn't request.
Run it yourself:
npm run bench.
API
73 methods across 13 groups. str accepts any registered source. A target is a string to locate or a number treated as a position. max/n default sensibly. Iterator methods return lazy IterableIterators.
1 · Slices relative to a target — l = last, b = back
| Method | Returns |
|---|---|
| substr(str, target, offset=∞) | slice after first target, length offset |
| lsubstr(str, target, offset=∞) | slice after last target |
| substrb(str, target, offset=∞) | slice before first target |
| lsubstrb(str, target, offset=∞) | slice before last target |
2 · Predicates — 0 / 1 / -1
| Method | Meaning |
|---|---|
| starts(str, target) | starts-with (numeric target ⇒ length compare) |
| ends(str, target) | ends-with |
| contains(str, target) | includes |
3 · Counters — no strings built
| Method | Counts |
|---|---|
| len(buf) | characters (code points) |
| wlen(buf) | wide chars (≥ U+10000 / surrogate pairs) |
| ulen(buf) | distinct characters |
| blen(buf) | utf-8 byte length, without encoding |
| glen(buf) | grapheme clusters (UAX #29) |
| dlen(buf) | display width in columns |
4 · Dropping characters
| Method | Returns |
|---|---|
| omit(str, omits) | lazy chars of str, skipping omits |
| omitn(str, omits) | indices of the dropped chars |
| clear(str, omits) | new string without omits (same ref if nothing matched) |
5 · Unique-character iterators — each char once, first-seen order
| Method | Yields the unique… |
|---|---|
| char / wchar / rchar | code units / code points / code units from the end |
| ichar | code points, case-folded (A≡a) |
| vchar / cchar | vowels / consonants (active alphabet) |
| uchar / lchar | uppercase / lowercase letters |
| nchar / pchar / schar | digits \p{Nd} / punctuation \p{P} / symbols \p{S} |
| mchar / zchar | combining marks \p{M} / invisibles \p{Cc}∪\p{Cf} |
| gchar | grapheme clusters |
All take (str, max=∞).
6 · Consecutive runs — aaabb structure
| Method | Returns |
|---|---|
| consc / rconsc | length of the first run (from start / end) |
| cons / rcons | the character of each run |
| consg / rconsg | the length of each run |
| ucons / urcons | run characters, skipping already-seen |
| flat(str, chars?, keep=1) | collapse repeats ('привееет' → 'привет') |
7 · Access by target
| Method | Returns |
|---|---|
| has(str, target) | char at index ±n (code units) or the char if present, else undefined |
| whas(str, target) | same, indexed in code points |
8 · Bytes & boundaries — utf-8 without materializing a string
| Method | Returns |
|---|---|
| badn(buf) | index of the first invalid utf-8 byte, or -1 |
| bom(buf) | byte length of a leading BOM, or 0 |
| cut(src, max, ell?) | truncate to max code points, never splitting a pair |
| bcut(src, max, ell?) | truncate to max utf-8 bytes |
| wcut(src, max, ell?) | truncate to max grapheme clusters |
cut/bcut/wcut return the source's own type; ell (ellipsis) is charged against the same budget.
9 · Search & compare
| Method | Returns |
|---|---|
| find(str, target, {all}?) | lazy match indices; target = string or many (one Aho-Corasick pass) |
| lfind / ifind | right-to-left / case-insensitive |
| near(a, b, max=∞, {damerau}?) | edit distance, or Infinity if it exceeds max |
| com(a, b) / comb(a, b) | length of common prefix / suffix (b = one string or many) |
10 · Segmentation & parsing
| Method | Returns |
|---|---|
| split(str, sep?, opts?) | lazy pieces; sep absent ⇒ newlines (\r\n\|\n\|\r as one) |
| splitn(str, sep?, opts?) | piece boundaries, allocating nothing |
| pair(str, i, opts?) / pairb | matching bracket index, nesting- and quote-aware |
| ind(str) | { char, size, min, mixed } indentation analysis |
| dedent(str) | strip the common indent |
| esc(str, chars?, opts?) / unesc | escape / unescape a character set |
| trim(str, chars?) / rtrim | trim a custom set (default \p{White_Space}) |
opts: { quote, quotes, esc, nest, limit, pairs } — quote/escape-aware splitting and matching (CSV, shell).
11 · Unicode analysis — security-grade
| Method | Returns |
|---|---|
| scr(str, max=∞) | unique script names |
| mix(str) | mixed-script check: 0 one / 1 mixed / -1 none (ignores Common/Inherited) |
| skel(str) | TR39-style skeleton — visually-equal strings collapse |
| deburr(str) | strip diacritics (search/sort, not display) |
12 · Metrics
| Method | Returns |
|---|---|
| ent(str, base=2, n=1) | Shannon entropy (bits by default; n-grams for n>1) |
| gram(str, n=2) / wgram | n-gram window start indices (code units / code points) |
| hash(str, n=2) | rolling Rabin–Karp hash of each n-gram window |
13 · Second tier
| Method | Returns |
|---|---|
| glob(str, pattern, {nocase}?) | * ? [abc] [a-z] [!neg] match, O(1) memory, no compile |
| style(str) | naming style: camel/pascal/snake/screaming/kebab/dot/mixed/… |
| wrap(str, width, opts?) | width-aware line wrap (display columns, ANSI-safe) |
Statics & factory
| Symbol | Purpose |
|---|---|
| strim.use(ctor, adapter) | register a source type |
| strim.alphabet(name, vowels?, consonants?) | register / fetch a vowel-consonant alphabet |
| strim.confuse(map) | add confusable code points for skel |
| _strim(overrides?) | build a configured instance (see below) |
| UNICODE | Unicode version of the tables |
| NotFound | error class for absent targets |
Extending & configuring
The default export is a singleton — you never write new. To specialize behavior (a custom terminal width, Turkish-i folding, tracing), pass overrides to _strim. An override may call super to wrap the original:
import { _strim } from '@slnknrr/str-im';
const traced = _strim({
find(str, target, opts) {
console.count('find');
return super.find(str, target, opts);
},
});Every internal call dispatches through this, so replacing one method replaces it everywhere it is used — no registry, no wiring.
⚠️ Keep configurations few and long-lived
This is not a style note; it is a performance contract. Internal call sites are monomorphic and V8 inlines them to zero cost — as long as few distinct
str-imclasses exist in the process. Create your configuration once, at module load, and reuse it._strimcaches by theoverridesobject (aWeakMap), so the same object always yields the same class.Spawn more than ~4 distinct configurations and internal call sites go megamorphic: the inline cache degrades to a hash lookup and every allocation guarantee in this library evaporates. If you find yourself calling
_strimin a hot path or a loop, you are holding it wrong.
Design notes
- Priming. Public "generators" are ordinary methods returning an already-primed generator: they validate, cross one dummy
yieldbarrier, and hand the generator back positioned at the first real value. One idleyieldper call, zero per element — the price of synchronous errors. - Indices, not tuples. Internal generators yield a single boundary number, never a
[value, length]pair — the value is the element at that index, the length is the next boundary minus this one. No per-element allocation, ever. Then-suffixed public methods (omitn,splitn) expose those raw boundaries. - Tables, offline.
\p{…}is invoked ~30 million times once, at build time (npm run tables), and compiled to base64-VLQ delta-coded ranges. At runtime only a binary search over a lazily-materializedInt32Arrayremains. Ask about punctuation and only the punctuation table comes into existence.
Scripts
| Command | Does |
|---|---|
| npm test | behavioral suite (node --test, zero dependencies) |
| npm run types | type-check the shipped declarations (tsc --noEmit) |
| npm run bench | the benchmarks above |
| npm run tables | regenerate src/tables.js from the engine's own ICU |
License
MIT + restrictions. The MIT License, with added limits — chiefly no use in AI/ML training and mandatory source-attribution headers. Full terms: Slinkin Restricted License 1.0. © 2026 Yury Slinkin.
