Skip to content

Commit 76f0ac6

Browse files
committed
feat: add BIC/SWIFT parser (ISO 9362)
1 parent 9643aae commit 76f0ac6

4 files changed

Lines changed: 195 additions & 8 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ npx jsr add @claudiu-ceia/ts-duckling
110110

111111
### Extract entities
112112

113-
Call `Duckling()` with no arguments to use **all 19 built-in parsers**:
113+
Call `Duckling()` with no arguments to use **all 20 built-in parsers**:
114114

115115
```ts
116116
import { Duckling } from "@claudiu-ceia/ts-duckling";
@@ -161,7 +161,7 @@ Use `.redact()` to replace matched entity spans with a mask character:
161161
```ts
162162
import { Duckling, PIIParsers } from "@claudiu-ceia/ts-duckling";
163163

164-
// Redact all PII (email, phone, IP, SSN, credit card, UUID, API key, IBAN, MAC, JWT)
164+
// Redact all PII (email, phone, IP, SSN, credit card, UUID, API key, IBAN, MAC, JWT, BIC)
165165
Duckling(PIIParsers).redact(
166166
"Patient email: john.doe@clinic.org, SSN 078-05-1120, phone +14155552671",
167167
);
@@ -391,6 +391,7 @@ Available via `PIIParsers` for targeted redaction with
391391
| **MAC address** | `mac_address` | `00:1A:2B:3C:4D:5E`, `001A.2B3C.4D5E` |
392392
| **JWT** | `jwt` | `eyJhbGciOiJIUzI1NiIs…` |
393393
| **Crypto** | `crypto_address` | `0x742d35Cc…f2bD18`, `bc1qar0srrr…`, `1BvBMSEY…` |
394+
| **BIC/SWIFT** | `bic` | `DEUTDEFF`, `BOFAUS3NXXX`, `COBADEFF100` |
394395

395396
## API reference
396397

@@ -417,7 +418,7 @@ function Duckling<T>(parsers: ParserTuple<T>): {
417418
};
418419
```
419420

420-
Creates an extractor/renderer/redactor. Without arguments, uses all 19 built-in
421+
Creates an extractor/renderer/redactor. Without arguments, uses all 20 built-in
421422
parsers and returns `AnyEntity[]`. When given an explicit parser array, the
422423
return type narrows to the union of those entity types.
423424

@@ -523,6 +524,7 @@ const PIIParsers: [
523524
MACAddressParser,
524525
JWTParser,
525526
CryptoAddressParser,
527+
BICParser,
526528
];
527529
```
528530

@@ -566,13 +568,13 @@ replace the span.
566568

567569
### `AnyEntity`
568570

569-
Union of all 19 built-in entity types. This is the return element type of
571+
Union of all 20 built-in entity types. This is the return element type of
570572
`Duckling().extract(...)`.
571573

572574
### `PIIEntity`
573575

574-
Union of the 11 PII entity types:
575-
`EmailEntity | PhoneEntity | IPAddressEntity | SSNEntity | CreditCardEntity | UUIDEntity | ApiKeyEntity | IBANEntity | MACAddressEntity | JWTEntity | CryptoAddressEntity`.
576+
Union of the 12 PII entity types:
577+
`EmailEntity | PhoneEntity | IPAddressEntity | SSNEntity | CreditCardEntity | UUIDEntity | ApiKeyEntity | IBANEntity | MACAddressEntity | JWTEntity | CryptoAddressEntity | BICEntity`.
576578

577579
## Caveats
578580

mod.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
CryptoAddress,
4040
type CryptoAddressEntity,
4141
} from "./src/CryptoAddress.ts";
42+
import { BIC, type BICEntity } from "./src/BIC.ts";
4243

4344
/**
4445
* Union of all entity types produced by the built-in parsers.
@@ -66,7 +67,8 @@ export type AnyEntity =
6667
| IBANEntity
6768
| MACAddressEntity
6869
| JWTEntity
69-
| CryptoAddressEntity;
70+
| CryptoAddressEntity
71+
| BICEntity;
7072

7173
const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
7274
Range.parser,
@@ -88,6 +90,7 @@ const DefaultParsers: [Parser<AnyEntity>, ...Parser<AnyEntity>[]] = [
8890
MACAddress.parser,
8991
JWT.parser,
9092
CryptoAddress.parser,
93+
BIC.parser,
9194
];
9295

9396
/**
@@ -108,7 +111,8 @@ export type PIIEntity =
108111
| IBANEntity
109112
| MACAddressEntity
110113
| JWTEntity
111-
| CryptoAddressEntity;
114+
| CryptoAddressEntity
115+
| BICEntity;
112116

113117
/**
114118
* Pre-built parser tuple targeting PII entities.
@@ -136,6 +140,7 @@ export const PIIParsers: ParserTuple<
136140
MACAddressEntity,
137141
JWTEntity,
138142
CryptoAddressEntity,
143+
BICEntity,
139144
]
140145
> = [
141146
Email.parser,
@@ -149,6 +154,7 @@ export const PIIParsers: ParserTuple<
149154
MACAddress.parser,
150155
JWT.parser,
151156
CryptoAddress.parser,
157+
BIC.parser,
152158
];
153159

154160
type NonEmptyArray<T> = [T, ...T[]];
@@ -515,3 +521,4 @@ export * from "./src/IBAN.ts";
515521
export * from "./src/MACAddress.ts";
516522
export * from "./src/JWT.ts";
517523
export * from "./src/CryptoAddress.ts";
524+
export * from "./src/BIC.ts";

src/BIC.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
type Context,
3+
createLanguage,
4+
map,
5+
optional,
6+
regex,
7+
seq,
8+
} from "@claudiu-ceia/combine";
9+
import type { Parser } from "@claudiu-ceia/combine";
10+
import { dot } from "./common.ts";
11+
import { ent, type Entity } from "./Entity.ts";
12+
import { guard } from "./guard.ts";
13+
import countries from "@data/countries-en-us" with { type: "json" };
14+
15+
const countryCodes = new Set(Object.keys(countries as Record<string, string>));
16+
17+
/**
18+
* BIC/SWIFT entity (Business Identifier Code — ISO 9362).
19+
*
20+
* Format: `BBBBCCLL(BBB)?`
21+
* - BBBB: 4-letter bank/institution code
22+
* - CC: 2-letter ISO 3166-1 country code
23+
* - LL: 2-character location code (letters or digits)
24+
* - BBB: optional 3-character branch code (letters or digits, "XXX" = head office)
25+
*/
26+
export type BICEntity = Entity<
27+
"bic",
28+
{
29+
bic: string;
30+
bank: string;
31+
country: string;
32+
location: string;
33+
branch: string | null;
34+
}
35+
>;
36+
37+
/**
38+
* Helper for constructing a `BICEntity`.
39+
*/
40+
export const bic = (
41+
value: BICEntity["value"],
42+
before: Context,
43+
after: Context,
44+
): BICEntity => {
45+
return ent(value, "bic", before, after);
46+
};
47+
48+
// Leaf tokens — regex only for character classes
49+
const bankCode = regex(/[A-Z]{4}/, "bank code");
50+
const countryChars = regex(/[A-Z]{2}/, "country code");
51+
const locationCode = regex(/[A-Z0-9]{2}/, "location code");
52+
const branchCode = regex(/[A-Z0-9]{3}/, "branch code");
53+
54+
type BICLanguage = {
55+
/** Full BIC: bank + country + location + optional branch, validated */
56+
Full: Parser<BICEntity>;
57+
parser: Parser<BICEntity>;
58+
};
59+
60+
/**
61+
* BIC/SWIFT parser language.
62+
*
63+
* Matches 8- or 11-character SWIFT/BIC codes with a valid ISO 3166-1 country
64+
* code at positions 5–6. Uses combinators for structure; regex only for leaf
65+
* character classes.
66+
*/
67+
export const BIC: BICLanguage = createLanguage<BICLanguage>({
68+
Full: () =>
69+
guard(
70+
map(
71+
seq(bankCode, countryChars, locationCode, optional(branchCode)),
72+
([bank, country, location, branch], b, a) =>
73+
bic(
74+
{
75+
bic: `${bank}${country}${location}${branch ?? ""}`,
76+
bank,
77+
country,
78+
location,
79+
branch: branch ?? null,
80+
},
81+
b,
82+
a,
83+
),
84+
),
85+
(entity) => countryCodes.has(entity.value.country),
86+
),
87+
parser: (s) => dot(s.Full),
88+
});

tests/BIC.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { assertEquals } from "@std/assert";
2+
import { BIC } from "../src/BIC.ts";
3+
import { Duckling } from "../mod.ts";
4+
5+
Deno.test("BIC 8-char (head office)", () => {
6+
const res = Duckling([BIC.parser]).extract("Send via DEUTDEFF please");
7+
assertEquals(res.length, 1);
8+
assertEquals(res[0].kind, "bic");
9+
assertEquals(res[0].text, "DEUTDEFF");
10+
assertEquals(res[0].value, {
11+
bic: "DEUTDEFF",
12+
bank: "DEUT",
13+
country: "DE",
14+
location: "FF",
15+
branch: null,
16+
});
17+
});
18+
19+
Deno.test("BIC 11-char (with branch)", () => {
20+
const res = Duckling([BIC.parser]).extract("Wire to BOFAUS3NXXX now");
21+
assertEquals(res.length, 1);
22+
assertEquals(res[0].kind, "bic");
23+
assertEquals(res[0].text, "BOFAUS3NXXX");
24+
assertEquals(res[0].value, {
25+
bic: "BOFAUS3NXXX",
26+
bank: "BOFA",
27+
country: "US",
28+
location: "3N",
29+
branch: "XXX",
30+
});
31+
});
32+
33+
Deno.test("BIC HSBC UK", () => {
34+
const res = Duckling([BIC.parser]).extract("Use HBUKGB4B for HSBC");
35+
assertEquals(res.length, 1);
36+
assertEquals(res[0].value.bank, "HBUK");
37+
assertEquals(res[0].value.country, "GB");
38+
});
39+
40+
Deno.test("BIC with numeric location", () => {
41+
const res = Duckling([BIC.parser]).extract("Code: BNPAFRPP ok");
42+
assertEquals(res.length, 1);
43+
assertEquals(res[0].value, {
44+
bic: "BNPAFRPP",
45+
bank: "BNPA",
46+
country: "FR",
47+
location: "PP",
48+
branch: null,
49+
});
50+
});
51+
52+
Deno.test("BIC invalid country rejected", () => {
53+
// ZZ is not a valid ISO 3166-1 country code
54+
const res = Duckling([BIC.parser]).extract("code AAAAZZBB ok");
55+
assertEquals(res.length, 0);
56+
});
57+
58+
Deno.test("BIC too short rejected (7 chars)", () => {
59+
const res = Duckling([BIC.parser]).extract("code DEUTDEF ok");
60+
assertEquals(res.length, 0);
61+
});
62+
63+
Deno.test("BIC 9 chars rejected (not 8 or 11)", () => {
64+
// 9 chars is neither a valid 8-char nor 11-char BIC
65+
const res = Duckling([BIC.parser]).extract("code DEUTDEFFA ok");
66+
// The parser will match DEUTDEFF (8) and leave the A, which is fine
67+
// because dot() requires a non-word boundary after. 'A' is a word char,
68+
// so this should NOT match.
69+
assertEquals(res.length, 0);
70+
});
71+
72+
Deno.test("BIC in default parsers", () => {
73+
const res = Duckling().extract("Wire to DEUTDEFF please");
74+
assertEquals(
75+
res.some((e) => e.kind === "bic"),
76+
true,
77+
);
78+
});
79+
80+
Deno.test("BIC with branch digits", () => {
81+
const res = Duckling([BIC.parser]).extract("SWIFT: COBADEFF100 here");
82+
assertEquals(res.length, 1);
83+
assertEquals(res[0].value, {
84+
bic: "COBADEFF100",
85+
bank: "COBA",
86+
country: "DE",
87+
location: "FF",
88+
branch: "100",
89+
});
90+
});

0 commit comments

Comments
 (0)