Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/core/src/offsets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Helpers for moving between the WASM splitter's coordinate system and
* JavaScript's.
*
* `split_offsets` and `chunk_offsets` return **UTF-8 byte** offsets, while
* `String.prototype.slice` indexes **UTF-16 code units**. The two only agree
* for ASCII text, so byte offsets must be converted before they touch a string.
*/

/**
* Number of UTF-8 bytes needed to encode a single code point.
*/
function utf8Length(codePoint: number): number {
if (codePoint < 0x80) return 1;
if (codePoint < 0x800) return 2;
if (codePoint < 0x10000) return 3;
return 4;
}

/**
* Convert UTF-8 byte offsets into UTF-16 code-unit offsets for the given text.
*
* Walks the string once, so the cost is linear in the length of the text
* regardless of how many offsets are supplied. ASCII-only text is detected up
* front and returned untouched, which keeps the common case free.
*
* @param text - The text the offsets refer to
* @param byteOffsets - Array of [start, end] UTF-8 byte offset pairs
* @returns Array of [start, end] UTF-16 code-unit offset pairs
*/
export function toCharOffsets(
text: string,
byteOffsets: Array<[number, number]>
): Array<[number, number]> {
if (byteOffsets.length === 0) return [];

// Byte and code-unit offsets coincide for ASCII, which is the common case.
if (!/[^\x00-\x7F]/.test(text)) return byteOffsets;

const targets = new Set<number>();
for (const [start, end] of byteOffsets) {
targets.add(start);
targets.add(end);
}

const sorted = [...targets].sort((a, b) => a - b);
const charByByte = new Map<number, number>();

let bytePos = 0;
let unitPos = 0;
let cursor = 0;

for (const char of text) {
while (cursor < sorted.length && sorted[cursor] <= bytePos) {
charByByte.set(sorted[cursor], unitPos);
cursor++;
}
bytePos += utf8Length(char.codePointAt(0)!);
unitPos += char.length;
}

// Any remaining target is at or past the end of the text.
while (cursor < sorted.length) {
charByByte.set(sorted[cursor], unitPos);
cursor++;
}

return byteOffsets.map(([start, end]) => [
charByByte.get(start)!,
charByByte.get(end)!,
]);
}
9 changes: 5 additions & 4 deletions packages/core/src/recursive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { init as initChunk, split_offsets, merge_splits } from '@chonkiejs/chunk';
import { toCharOffsets } from './offsets';
import { Tokenizer } from '@/tokenizer';
import { Chunk, RecursiveRules, RecursiveLevel, IncludeDelim } from '@/types';

Expand Down Expand Up @@ -134,11 +135,11 @@ export class RecursiveChunker {
private splitText(text: string, level: RecursiveLevel): string[] {
// Whitespace splitting - use WASM split with space delimiter
if (level.whitespace) {
const offsets = split_offsets(text, {
const offsets = toCharOffsets(text, split_offsets(text, {
delimiters: ' ',
includeDelim: 'none',
minChars: 0
});
}));
return offsets.map(([start, end]) => text.slice(start, end));
}

Expand All @@ -159,11 +160,11 @@ export class RecursiveChunker {
level.includeDelim === 'prev' ? 'prev' :
level.includeDelim === 'next' ? 'next' : 'none';

const offsets = split_offsets(text, {
const offsets = toCharOffsets(text, split_offsets(text, {
delimiters: delims,
includeDelim,
minChars: this.minCharactersPerChunk
});
}));

return offsets.map(([start, end]) => text.slice(start, end));
}
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { init as initChunk, split_offsets } from '@chonkiejs/chunk';
import { toCharOffsets } from './offsets';
import { Tokenizer } from '@/tokenizer';
import { Chunk } from '@/types';

Expand Down Expand Up @@ -369,11 +370,11 @@ export class SemanticChunker {
const raw = this.delimiters.join('');
const delimChars = [...new Set(raw)].filter(c => c !== ' ').join('');

const offsets = split_offsets(text, {
const offsets = toCharOffsets(text, split_offsets(text, {
delimiters: delimChars,
includeDelim: this.includeDelim === 'none' ? 'none' : this.includeDelim,
minChars: this.minCharactersPerSentence,
});
}));

if (offsets.length === 0) return [];

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/sentence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { split_offsets, merge_splits } from '@chonkiejs/chunk';
import { toCharOffsets } from './offsets';
import { initWasm } from '@/wasm';
import { Tokenizer } from '@/tokenizer';
import { Chunk, IncludeDelim } from '@/types';
Expand Down Expand Up @@ -141,11 +142,11 @@ export class SentenceChunker {

// All single-byte delimiters: use WASM split_offsets
const delimStr = this.delim.join('');
return split_offsets(text, {
return toCharOffsets(text, split_offsets(text, {
delimiters: delimStr,
includeDelim: this.includeDelim,
minChars: this.minCharactersPerSentence,
});
}));
}

/**
Expand Down
147 changes: 147 additions & 0 deletions packages/core/tests/unicode-offsets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { RecursiveChunker, SentenceChunker } from '../src';
import { toCharOffsets } from '../src/offsets';

// The WASM splitter reports UTF-8 byte offsets while String.prototype.slice
// indexes UTF-16 code units. The two agree only for ASCII, so these tests pair
// each non-ASCII input with an ASCII input of identical code-unit length:
// chunking must not care which one it is given.
// 'é' / '–' -> 1 code unit, 2-3 UTF-8 bytes (ASCII twin: 'e' / '-')
// '🔗' -> 2 code units, 4 UTF-8 bytes (ASCII twin: 'ab')
const PARITY_CASES = {
accented: { wide: 'café '.repeat(400), ascii: 'cafe '.repeat(400) },
dashed: { wide: 'a – b '.repeat(400), ascii: 'a - b '.repeat(400) },
emoji: { wide: '🔗 link '.repeat(300), ascii: 'ab link '.repeat(300) },
};

describe('Unicode offset handling', () => {
describe('toCharOffsets', () => {
it('should leave ASCII offsets untouched', () => {
expect(toCharOffsets('ab cd', [[0, 2], [3, 5]])).toEqual([[0, 2], [3, 5]]);
});

it('should convert byte offsets for two-byte characters', () => {
expect(toCharOffsets('é cd', [[0, 2], [3, 5]])).toEqual([[0, 1], [2, 4]]);
});

it('should convert byte offsets for astral characters', () => {
expect(toCharOffsets('🔗 cd', [[0, 4], [5, 7]])).toEqual([[0, 2], [3, 5]]);
});

it('should handle an empty offset list', () => {
expect(toCharOffsets('🔗', [])).toEqual([]);
});
});

describe('RecursiveChunker', () => {
// A long line without sentence delimiters forces the recursion down to the
// whitespace level, which is where offsets are applied to the string.
for (const [name, { wide, ascii }] of Object.entries(PARITY_CASES)) {
for (const chunkSize of [64, 256, 1024]) {
it(`should chunk ${name} text like its ASCII twin (chunkSize ${chunkSize})`, async () => {
const chunker = await RecursiveChunker.create({
chunkSize,
minCharactersPerChunk: 16,
});

const wideChunks = await chunker.chunk(wide);
const asciiChunks = await chunker.chunk(ascii);

expect(wideChunks.map(chunk => chunk.text.length)).toEqual(
asciiChunks.map(chunk => chunk.text.length)
);
expect(wideChunks.map(chunk => chunk.startIndex)).toEqual(
asciiChunks.map(chunk => chunk.startIndex)
);
});
}
}

it('should not drop characters from the middle of the text', async () => {
const chunker = await RecursiveChunker.create({
chunkSize: 64,
minCharactersPerChunk: 16,
});
const text = 'café '.repeat(400);

const chunks = await chunker.chunk(text);

// The whitespace level drops the final delimiter for ASCII text too, so
// compare against the trimmed source rather than the raw input.
expect(chunks.map(chunk => chunk.text).join('')).toBe(text.trimEnd());
});

it('should never split a surrogate pair', async () => {
const chunker = await RecursiveChunker.create({
chunkSize: 64,
minCharactersPerChunk: 16,
});

const chunks = await chunker.chunk(PARITY_CASES.emoji.wide);

for (const chunk of chunks) {
expect(chunk.text.isWellFormed()).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check declared TS lib/target and Node engine requirements
fd tsconfig.json | xargs -I{} sh -c 'echo "== {} =="; cat {}'
fd package.json -d 2 | xargs -I{} sh -c 'echo "== {} =="; jq ".engines" {} 2>/dev/null'
fd '.nvmrc|.tool-versions' -H

Repository: feyninc/chonkiejs

Length of output: 2641


🏁 Script executed:

#!/bin/bash
set -e
echo "== package.json files with engines/scripts =="
git ls-files '*package.json' | while read -r f; do
  echo "== $f =="
  jq '{name, type, engines, scripts, dependencies, devDependencies}' "$f"
done

echo
echo "== workflow node-version declarations =="
if [ -d .github/workflows ]; then
  fd -a . .github/workflows -t f | while read -r f; do echo "== $f =="; rg -n "node-version|nodejs|setup-node|vitest|pnpm|npm|yarn|bun" "$f" || true; done
else
  echo "no .github/workflows directory"
fi

echo
echo "== unicode-offsets test relevant lines =="
fd unicode-offsets.test.ts -t f | while read -r f; do
  echo "== $f =="
  sed -n '1,180p' "$f"
done

echo
echo "== TypeScript declarations for isWellFormed in built-in libs =="
fd 'lib.es2023.*.d.ts|lib.es2024.*.d.ts|lib.esnext.*.d.ts' /var 2>/dev/null || true

Repository: feyninc/chonkiejs

Length of output: 9926


🏁 Script executed:

#!/bin/bash -
# Inspect packages/core test config inheritance path and any lockfile engine pins.
set -e
echo "== packages/core/package.json =="
jq '{name, type, engines, scripts, dependencies, devDependencies}' packages/core/package.json

echo
echo "== packages/core/tests/tsconfig.json =="
cat packages/core/tests/tsconfig.json

echo
echo "== packages/core/tsconfig.json =="
cat packages/core/tsconfig.json

echo
echo "== package manager files =="
git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true

echo
echo "== lockfile engines/node declarations (first 120 matches) =="
for f in $(git ls-files | rg '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock)$' || true); do
  echo "== $f =="
  rg -n "engines:|node:|node-version|`@chonkiejs/core`|typescript|vitest" "$f" | head -120 || true
done

echo
echo "== TypeScript installed availability =="
node - <<'JS'
try {
  const fs = require('fs');
  const path = require('path');
  for (const root of ['node_modules', process.cwd() + '/node_modules']) {
    const file = path.join(root, 'typescript/lib/lib.es2024.string.d.ts');
    if (fs.existsSync(file)) {
      console.log('types file exists:', file);
      const text = fs.readFileSync(file, 'utf8');
      console.log(text.includes('isWellFormed') ? 'isWellFormed declaration present' : 'isWellFormed declaration absent');
    }
  }
  console.log('node', process.version);
} catch (e) {
  console.log('type check unavailable:', e.message);
}
JS

Repository: feyninc/chonkiejs

Length of output: 10387


Raise the test/CI Node lower bound before using String.isWellFormed().

packages/core/tests inherits packages/core/tsconfig.json, which still uses lib: ["ES2021"], so these calls will fail type-checking unless that lib is updated. Add Node 20 to the repo and pnpm-workspace.yaml declarations too; the current workspace/engine config only requires Node 18, but String.prototype.isWellFormed() needs Node 20.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/tests/unicode-offsets.test.ts` at line 82, Raise the repository
and workspace Node engine lower bounds from 18 to 20, including the relevant
declarations in package configuration and pnpm-workspace.yaml. Update
packages/core/tsconfig.json to use a library target that defines
String.prototype.isWellFormed(), then retain the isWellFormed() test assertion
in unicode-offsets.test.ts.

}
});

it('should report offsets that index back into the source text', async () => {
const chunker = await RecursiveChunker.create({
chunkSize: 64,
minCharactersPerChunk: 16,
});
const text = 'café '.repeat(400);

const chunks = await chunker.chunk(text);

for (const chunk of chunks) {
expect(text.slice(chunk.startIndex, chunk.endIndex)).toBe(chunk.text);
}
});
});

describe('SentenceChunker', () => {
// Single-character delimiters take the WASM path; the multi-character
// defaults are split in JS and were never affected.
const delim = ['.', '!', '?'];
const text = 'Le café était très bon. Un peu cher. Mais bon quand même. Voilà.';

it('should not run offsets past the end of the text', async () => {
const chunker = await SentenceChunker.create({
chunkSize: 32,
minCharactersPerSentence: 4,
delim,
});

const chunks = await chunker.chunk(text);

expect(chunks[chunks.length - 1].endIndex).toBe(text.length);
});

it('should split sentences at delimiters rather than mid-word', async () => {
const chunker = await SentenceChunker.create({
chunkSize: 32,
minCharactersPerSentence: 4,
delim,
});

const chunks = await chunker.chunk(text);

for (const chunk of chunks) {
expect(chunk.text.trimEnd().endsWith('.')).toBe(true);
}
});

it('should never split a surrogate pair', async () => {
const chunker = await SentenceChunker.create({
chunkSize: 64,
minCharactersPerSentence: 8,
delim,
});

const chunks = await chunker.chunk('Voir le lien 🔗 ici. '.repeat(60));

for (const chunk of chunks) {
expect(chunk.text.isWellFormed()).toBe(true);
}
});
});
});