Skip to content
Merged
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ import { createPhoneScanner } from "@textfilters/phone";
const scanner = createPhoneScanner();
const codePoints = Array.from("call +1 202 555 0187");
const result = scanner.scan({ text: "call +1 202 555 0187", codePoints });
const hasPhone = scanner.check({ text: "call +1 202 555 0187", codePoints });

scanner.scan({ text: "call +1 202 555 0187", codePoints }, (match) => {
console.log(match.range);
return false;
});
```

The default shared instance is exported as `filter`. It has stable `name: "phone"`.
Expand All @@ -68,8 +74,9 @@ False-positive guards keep date-like, time-like, coordinate-like, IP/server-like
See [docs/architecture.md](docs/architecture.md) for the parser flow, module map,
and change guide.

Run `npm run benchmark:phone` from this package to compare clean, long clean,
direct phone, phone-like, and late-match cases on the same machine.
Run `npm run benchmark:phone` from this package to compare scanner setup,
`check()`, clean, low-digit, direct phone, phone-like, and late-match cases on
the same machine.

## Related Textfilters Packages

Expand Down
11 changes: 8 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ The public API is intentionally small:
- `PhoneFilterConfig` currently supports `maskChar`.
- `maskChar` is normalized by `@textfilters/core` before masking.
- `createPhoneScanner()` exposes the same matching behavior as a range scanner.
- `createPhoneScanner().check(input)` returns a boolean without collecting all
ranges.
- `createPhoneScanner().scan(input, sink)` streams ranges and supports early
stop when the sink returns `false`.
- `scanPhoneRanges(text)` returns code point ranges directly for callers that
want to compose masking through `@textfilters/core`.

Expand Down Expand Up @@ -80,9 +84,10 @@ with NFKC and lowercasing through `@textfilters/core`, then Unicode decimal
digits are folded to ASCII in `digits.ts`.

Before metadata creation, the public scanner checks for a cheap folded digit
count signal. Clearly clean text returns no ranges without candidate parsing;
numeric candidate text still runs through the same group validation and
false-positive guards as before.
count signal. Shared-style hints such as digit count, plus sign, punctuation, and
text length can reject low-digit text before candidate parsing. Clearly clean
text returns no ranges without candidate parsing; numeric candidate text still
runs through the same group validation and false-positive guards as before.

The raw arrays and source code point arrays stay aligned: every source code point
has exactly one metadata slot. Zero-width code points can be skipped by cursors
Expand Down
29 changes: 28 additions & 1 deletion scripts/benchmark-phone.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { performance } from "node:perf_hooks";
import { createPhoneFilter } from "../dist/index.js";
import { createPhoneFilter, createPhoneScanner } from "../dist/index.js";

const ITERATIONS = 1_000;
const SETUP_ITERATIONS = 100;

const SHORT_CLEAN = "Hello world";
const LONG_CLEAN = "The quick brown fox jumps over the lazy dog. ".repeat(50);
const LOW_DIGIT_TEXT = "Use code 12345 before Friday";
const DIRECT_PHONE = "Call +1 202 555 0187 for details";
const PHONE_LIKE = "Call 202.555.0187 for details";
const LATE_MATCH =
Expand Down Expand Up @@ -38,11 +39,37 @@ function printResults(results) {
}

const filter = createPhoneFilter();
const scanner = createPhoneScanner();
const input = (text) => ({ text, codePoints: Array.from(text) });
const hintedInput = (text) => ({
text,
codePoints: Array.from(text),
hints: {
textLength: text.length,
digitCount: Array.from(text).filter((codePoint) =>
/\p{Nd}/u.test(codePoint),
).length,
hasPlus: text.includes("+"),
hasPunctuation: /[^\p{L}\p{N}\s]/u.test(text),
},
});

printResults([
bench("createPhoneFilter()", () => createPhoneFilter(), SETUP_ITERATIONS),
bench("createPhoneScanner()", () => createPhoneScanner(), SETUP_ITERATIONS),
bench("check short clean", () => scanner.check(input(SHORT_CLEAN))),
bench("check hinted short clean", () =>
scanner.check(hintedInput(SHORT_CLEAN)),
),
bench("check low digit text", () => scanner.check(input(LOW_DIGIT_TEXT))),
bench("check hinted low digit text", () =>
scanner.check(hintedInput(LOW_DIGIT_TEXT)),
),
bench("check direct phone", () => scanner.check(input(DIRECT_PHONE))),
bench("check late-match phone", () => scanner.check(input(LATE_MATCH))),
bench("censor short clean", () => filter.censor(SHORT_CLEAN)),
bench("censor long clean", () => filter.censor(LONG_CLEAN)),
bench("censor low digit text", () => filter.censor(LOW_DIGIT_TEXT)),
bench("censor direct phone", () => filter.censor(DIRECT_PHONE)),
bench("censor phone-like", () => filter.censor(PHONE_LIKE)),
bench("censor late-match phone", () => filter.censor(LATE_MATCH)),
Expand Down
109 changes: 105 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
type TextCodePointRange,
} from "@textfilters/core";
import { toRawChar } from "./digits.js";
import { collectRanges, createMeta } from "./parser.js";
import {
collectCandidateRangeMatches,
collectRanges,
createMeta,
} from "./parser.js";

export interface PhoneFilterConfig {
readonly maskChar?: string;
Expand All @@ -23,27 +27,56 @@ export interface PhoneScannerConfig {}
export interface PhoneScanInput {
readonly text: string;
readonly codePoints: readonly string[];
readonly hints?: {
readonly textLength?: number;
readonly digitCount?: number;
readonly hasPlus?: boolean;
readonly hasPunctuation?: boolean;
};
}

export interface PhoneRangeScanResult {
readonly ranges: readonly TextCodePointRange[];
}

export interface PhoneRangeMatch {
readonly range: TextCodePointRange;
}

export type PhoneRangeMatchSink = (match: PhoneRangeMatch) => boolean | void;

export interface PhoneRangeScanner {
readonly name: typeof PHONE_FILTER_NAME;
readonly allocationAware: true;
check(input: PhoneScanInput): boolean;
scan(input: PhoneScanInput): PhoneRangeScanResult;
scan(input: PhoneScanInput, sink: PhoneRangeMatchSink): boolean | void;
}

export function createPhoneScanner(
_config: PhoneScannerConfig = {},
): PhoneRangeScanner {
return {
name: PHONE_FILTER_NAME,
scan(input) {
function scan(input: PhoneScanInput): {
ranges: readonly TextCodePointRange[];
};
function scan(input: PhoneScanInput, sink: PhoneRangeMatchSink): boolean;
function scan(input: PhoneScanInput, sink?: PhoneRangeMatchSink) {
if (sink === undefined) {
return {
ranges: scanPhoneRanges(input.text),
};
}

return scanPhoneRangeMatches(input, sink);
}

return {
name: PHONE_FILTER_NAME,
allocationAware: true,
check(input) {
return checkPhoneRanges(input);
},
scan,
};
}

Expand All @@ -55,6 +88,53 @@ export function scanPhoneRanges(text: unknown): readonly TextCodePointRange[] {
return collectRanges(meta);
}

export function checkPhoneRanges(input: PhoneScanInput): boolean {
if (!hasPhoneCandidateInput(input)) return false;

const meta = createMeta(input.text);
let found = false;
collectCandidateRangeMatches(meta, () => {
found = true;
return false;
});
return found;
}

export function scanPhoneRangeMatches(
input: PhoneScanInput,
sink: PhoneRangeMatchSink,
): boolean {
if (!hasPhoneCandidateInput(input)) return true;

const meta = createMeta(input.text);
let pending: TextCodePointRange | null = null;
let stopped = false;

const completed = collectCandidateRangeMatches(meta, (range) => {
if (pending === null) {
pending = range;
return true;
}

if (range[0] <= pending[1]) {
pending = [pending[0], Math.max(pending[1], range[1])];
return true;
}

if (sink({ range: pending }) === false) {
stopped = true;
return false;
}

pending = range;
return true;
});

if (stopped) return false;
if (pending === null) return completed !== false;
return sink({ range: pending }) !== false && completed !== false;
}

export function createPhoneFilter(config: PhoneFilterConfig = {}): PhoneFilter {
const scanner = createPhoneScanner();
const maskChar = normalizeMaskChar(config.maskChar);
Expand Down Expand Up @@ -87,3 +167,24 @@ function hasPhoneCandidate(source: string): boolean {

return false;
}

function hasPhoneCandidateInput(input: PhoneScanInput): boolean {
const hints = input.hints;
if (
hints?.digitCount !== undefined &&
hints.digitCount < 10 &&
hasAsciiOnly(input.text)
) {
return false;
}

return hasPhoneCandidate(input.text);
Comment thread
alyldas marked this conversation as resolved.
}

function hasAsciiOnly(source: string): boolean {
for (let index = 0; index < source.length; index++) {
if (source.charCodeAt(index) > 0x7f) return false;
}

return true;
}
2 changes: 1 addition & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createMeta } from "./meta.js";
export { collectRanges } from "./ranges.js";
export { collectCandidateRangeMatches, collectRanges } from "./ranges.js";
6 changes: 5 additions & 1 deletion src/ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import {
type TextCodePointRange,
} from "@textfilters/core";
import { type TextMeta } from "./meta.js";
import { collectCandidateRanges } from "./scanner.js";
import {
collectCandidateRangeMatches,
collectCandidateRanges,
} from "./scanner.js";

export type CodePointRange = TextCodePointRange;
export { collectCandidateRangeMatches };

export const collectRanges = (meta: TextMeta): readonly CodePointRange[] =>
mergeCodePointRanges(collectCandidateRanges(meta));
16 changes: 14 additions & 2 deletions src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,15 +415,27 @@ export const collectCandidateRanges = (
meta: TextMeta,
): readonly CodePointRange[] => {
const ranges: CodePointRange[] = [];
collectCandidateRangeMatches(meta, (range) => {
ranges.push(range);
});
return ranges;
};

export type PhoneCandidateRangeSink = (range: CodePointRange) => boolean | void;

export const collectCandidateRangeMatches = (
meta: TextMeta,
sink: PhoneCandidateRangeSink,
): boolean => {
for (let i = 0; i < meta.codePoints.length; i++) {
const candidate = parsePhoneCandidate(meta, i);
if (!candidate) continue;
if ("rejectedUntil" in candidate) {
i = Math.max(i, candidate.rejectedUntil - 1);
continue;
}
ranges.push([candidate.start, candidate.end]);
if (sink([candidate.start, candidate.end]) === false) return false;
i = Math.max(i, candidate.end - 1);
}
return ranges;
return true;
};
Loading