Skip to content

Commit f84b9c5

Browse files
committed
feat: make Duckling.extract accept strings
1 parent 303dba3 commit f84b9c5

18 files changed

Lines changed: 1068 additions & 1384 deletions

README.md

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Online playground: https://claudiuceia.github.io/ts-duckling/
2222
## Install (Deno / JSR)
2323

2424
```ts
25-
import { Duckling } from "jsr:@claudiu-ceia/ts-duckling@^0.0.13";
25+
import { Duckling } from "jsr:@claudiu-ceia/ts-duckling@^0.0.14";
2626
```
2727

2828
## Quickstart
@@ -33,20 +33,14 @@ import {
3333
Email,
3434
Time,
3535
URL,
36-
} from "jsr:@claudiu-ceia/ts-duckling@^0.0.13";
36+
} from "jsr:@claudiu-ceia/ts-duckling@^0.0.14";
3737

3838
const text =
3939
"Email me at foo@example.com and visit https://example.com tomorrow.";
40-
const res = Duckling([Email.parser, URL.parser, Time.parser]).extract({
40+
const entities = Duckling([Email.parser, URL.parser, Time.parser]).extract(
4141
text,
42-
index: 0,
43-
});
44-
45-
if (res.success) {
46-
console.log(res.value);
47-
} else {
48-
console.error(res.expected, res.location);
49-
}
42+
);
43+
console.log(entities);
5044
```
5145

5246
## Supported Entities
@@ -80,23 +74,8 @@ entities:
8074

8175
```ts
8276
// ts-duckling falsely assumes that 6/2022 is a date
83-
const res = Duckling([Time.parser]).extract({
84-
text: "6/2022 is 0.00296735905",
85-
index: 0,
86-
});
87-
88-
if (res.success) {
89-
console.log(res.value);
90-
// [
91-
// {
92-
// kind: "time",
93-
// start: 0,
94-
// end: 6,
95-
// text: "6/2022",
96-
// value: { when: "...", grain: "day", era: "CE" },
97-
// },
98-
// ]
99-
}
77+
const entities2 = Duckling([Time.parser]).extract("6/2022 is 0.00296735905");
78+
console.log(entities2);
10079
```
10180

10281
## Adding New Entity Types
@@ -105,7 +84,7 @@ Define a parser that returns an `Entity`, then pass its `.parser` to `Duckling`.
10584

10685
```ts
10786
import {
108-
createLanguageThis,
87+
createLanguage,
10988
map,
11089
type Parser,
11190
regex,
@@ -115,31 +94,29 @@ import {
11594
Duckling,
11695
ent,
11796
type Entity,
118-
} from "jsr:@claudiu-ceia/ts-duckling@^0.0.13";
97+
} from "jsr:@claudiu-ceia/ts-duckling@^0.0.14";
11998

12099
type HashtagEntity = Entity<"hashtag", { tag: string }>;
121100

122-
const Hashtag = createLanguageThis({
123-
Full() {
124-
return map(
101+
type HashtagLanguage = {
102+
Full: Parser<HashtagEntity>;
103+
parser: Parser<HashtagEntity>;
104+
};
105+
106+
const Hashtag = createLanguage<HashtagLanguage>({
107+
Full: () =>
108+
map(
125109
regex(/#[A-Za-z0-9_]{2,64}/, "hashtag"),
126110
(m, b, a) => ent({ tag: m.slice(1) }, "hashtag", b, a),
127-
);
128-
},
129-
parser() {
130-
return this.Full;
131-
},
111+
),
112+
parser: (s) => s.Full,
132113
});
133114

134-
// `Duckling` is typed to the built-in entity union (`AnyEntity`).
135-
// To mix in custom entities, widen the type (or cast) and handle it downstream.
136-
const res2 = Duckling([Hashtag.parser as unknown as Parser<AnyEntity>]).extract(
137-
{
138-
text: "hello #duckling",
139-
index: 0,
140-
},
115+
type MyEntity = AnyEntity | HashtagEntity;
116+
const entities3 = Duckling<MyEntity>([Hashtag.parser]).extract(
117+
"hello #duckling",
141118
);
142-
if (res2.success) console.log(res2.value);
119+
console.log(entities3);
143120
```
144121

145122
# License

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@claudiu-ceia/ts-duckling",
3-
"version": "0.0.13",
3+
"version": "0.0.14",
44
"exports": {
55
".": "./mod.ts"
66
},

docs/worker.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,9 @@ self.onmessage = async (ev) => {
6363
: [];
6464

6565
const t0 = performance.now();
66-
const res = m.Duckling(parsers).extract({ text: input, index: 0 });
66+
const entities = m.Duckling(parsers).extract(input);
6767
const ms = performance.now() - t0;
6868

69-
const entities = res.success ? res.value : [];
7069
self.postMessage({
7170
type: "result",
7271
reqId,

mod.ts

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@claudiu-ceia/combine";
1515
import { recognizeAt, step } from "@claudiu-ceia/combine/nondeterministic";
1616
import { __, dot, word } from "./src/common.ts";
17+
import type { Entity } from "./src/Entity.ts";
1718
import { Quantity, type QuantityEntity } from "./src/Quantity.ts";
1819
import { Range, type RangeEntity } from "./src/Range.ts";
1920
import { Temperature, type TemperatureEntity } from "./src/Temperature.ts";
@@ -54,42 +55,71 @@ export type AnyEntity =
5455
| UUIDEntity
5556
| ApiKeyEntity;
5657

57-
type DucklingLanguage = {
58-
Entity: () => Parser<AnyEntity[]>;
58+
type DucklingLanguage<E extends Entity<string, unknown>> = {
59+
Entity: () => Parser<E[]>;
5960
Unstructured: () => Parser<string>;
60-
extract: () => Parser<AnyEntity[]>;
61+
extract: () => Parser<E[]>;
6162
};
6263

6364
/**
64-
* Creates a Duckling-like extractor language.
65+
* A Duckling-like extractor.
6566
*
66-
* It parses arbitrary text and returns entity matches produced by the provided
67+
* This is a convenience wrapper around the internal parser combinator grammar,
68+
* so callers can simply pass a string.
69+
*/
70+
export type DucklingExtractor<E extends Entity<string, unknown>> = {
71+
/**
72+
* Extract entities from `text` (starting at index 0).
73+
*/
74+
extract: (text: string) => E[];
75+
/**
76+
* Extract entities starting at `index` in `text`.
77+
*/
78+
extractAt: (input: { text: string; index: number }) => E[];
79+
/**
80+
* Low-level parser for advanced use.
81+
*
82+
* Note: this parser expects a `{ text, index }` context input.
83+
*/
84+
extractParser: Parser<E[]>;
85+
};
86+
87+
/**
88+
* Creates a Duckling-like extractor.
89+
*
90+
* It scans arbitrary text and returns entity matches produced by the provided
6791
* entity parsers.
6892
*/
69-
export const Duckling: (
70-
parsers?: Parser<AnyEntity>[],
71-
) => ReturnType<typeof createLanguageThis<DucklingLanguage>> = (
72-
parsers: Parser<AnyEntity>[] = [
73-
Range.parser,
74-
Email.parser,
75-
URL.parser,
76-
UUID.parser,
77-
Phone.parser,
78-
IPAddress.parser,
79-
SSN.parser,
80-
CreditCard.parser,
81-
Time.parser,
82-
Temperature.parser,
83-
Quantity.parser,
84-
Location.parser,
85-
Institution.parser,
86-
Language.parser,
87-
ApiKey.parser,
88-
],
89-
) =>
90-
createLanguageThis<DucklingLanguage>({
93+
const DefaultParsers: Parser<AnyEntity>[] = [
94+
Range.parser,
95+
Email.parser,
96+
URL.parser,
97+
UUID.parser,
98+
Phone.parser,
99+
IPAddress.parser,
100+
SSN.parser,
101+
CreditCard.parser,
102+
Time.parser,
103+
Temperature.parser,
104+
Quantity.parser,
105+
Location.parser,
106+
Institution.parser,
107+
Language.parser,
108+
ApiKey.parser,
109+
];
110+
111+
export function Duckling(): DucklingExtractor<AnyEntity>;
112+
export function Duckling<E extends Entity<string, unknown>>(
113+
parsers: Parser<E>[],
114+
): DucklingExtractor<E>;
115+
export function Duckling<E extends Entity<string, unknown>>(
116+
parsers?: Parser<E>[],
117+
): DucklingExtractor<E> {
118+
const ps = parsers ?? (DefaultParsers as unknown as Parser<E>[]);
119+
120+
const lang = createLanguageThis<DucklingLanguage<E>>({
91121
Entity() {
92-
if (parsers.length === 0) {
122+
if (ps.length === 0) {
93123
return (ctx) => failure(ctx, "entity");
94124
}
95125

@@ -98,7 +128,7 @@ export const Duckling: (
98128
// overlapping matches (useful for debug/analysis and the demo UI).
99129
const p = step(
100130
recognizeAt(
101-
...(parsers as [Parser<AnyEntity>, ...Parser<AnyEntity>[]]),
131+
...(ps as [Parser<E>, ...Parser<E>[]]),
102132
),
103133
"shortest",
104134
);
@@ -117,14 +147,28 @@ export const Duckling: (
117147
skip1(eof()),
118148
),
119149
([...matches]) =>
120-
matches.filter((m): m is AnyEntity[] => Array.isArray(m)).flat(),
150+
matches.filter((m): m is E[] => Array.isArray(m)).flat(),
121151
),
122152
),
123153
([, res]) => res,
124154
);
125155
},
126156
});
127157

158+
const extractAt = (input: { text: string; index: number }): E[] => {
159+
const res = lang.extract(input);
160+
return res.success ? res.value : [];
161+
};
162+
163+
const extract = (text: string): E[] => extractAt({ text, index: 0 });
164+
165+
return {
166+
extract,
167+
extractAt,
168+
extractParser: lang.extract,
169+
};
170+
}
171+
128172
export * from "./src/Entity.ts";
129173
export * from "./src/Quantity.ts";
130174
export * from "./src/Range.ts";

tests/CreditCard.test.ts

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,43 @@ import { assertEquals } from "@std/assert";
22
import { CreditCard, Duckling } from "../mod.ts";
33

44
Deno.test("Credit card (Luhn)", () => {
5-
const res = Duckling([CreditCard.parser]).extract({
6-
text: "use 4242 4242 4242 4242 for testing",
7-
index: 0,
8-
});
5+
const res = Duckling([CreditCard.parser]).extract(
6+
"use 4242 4242 4242 4242 for testing",
7+
);
98

10-
assertEquals(res.success, true);
11-
if (res.success) {
12-
assertEquals(res.value, [
13-
{
14-
start: 4,
15-
end: 23,
16-
kind: "credit_card",
17-
text: "4242 4242 4242 4242",
18-
value: {
19-
digits: "4242424242424242",
20-
},
9+
assertEquals(res, [
10+
{
11+
start: 4,
12+
end: 23,
13+
kind: "credit_card",
14+
text: "4242 4242 4242 4242",
15+
value: {
16+
digits: "4242424242424242",
2117
},
22-
]);
23-
}
18+
},
19+
]);
2420
});
2521

2622
Deno.test("Credit card invalid does not parse", () => {
27-
const res = Duckling([CreditCard.parser]).extract({
28-
text: "use 4242 4242 4242 4243 for testing",
29-
index: 0,
30-
});
23+
const res = Duckling([CreditCard.parser]).extract(
24+
"use 4242 4242 4242 4243 for testing",
25+
);
3126

32-
assertEquals(res.success, true);
33-
if (res.success) {
34-
assertEquals(res.value, []);
35-
}
27+
assertEquals(res, []);
3628
});
3729

3830
Deno.test("Duckling extracts CC + 4-digit groups (step shortest)", () => {
3931
const text = "CC 4242 4242 4242 4242";
40-
const res = Duckling().extract({ text, index: 0 });
41-
42-
assertEquals(res.success, true);
43-
if (!res.success) return;
32+
const res = Duckling().extract(text);
4433

4534
assertEquals(
46-
res.value.some((e) =>
35+
res.some((e) =>
4736
e.kind === "credit_card" && e.text === "4242 4242 4242 4242"
4837
),
4938
true,
5039
);
5140

52-
const groups = res.value.filter((e) =>
53-
e.kind === "quantity" && e.text === "4242"
54-
);
41+
const groups = res.filter((e) => e.kind === "quantity" && e.text === "4242");
5542
assertEquals(groups.length, 4);
5643
assertEquals(groups.map((e) => [e.start, e.end]), [
5744
[3, 7],

tests/Email.test.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,19 @@ import { assertEquals } from "@std/assert";
22
import { Duckling } from "../mod.ts";
33

44
Deno.test("Email in sentence", () => {
5-
const res = Duckling().extract({
6-
text: `I've never emailed no-reply+foo@some.domain.dev before`,
7-
index: 0,
8-
});
5+
const res = Duckling().extract(
6+
"I've never emailed no-reply+foo@some.domain.dev before",
7+
);
98

10-
assertEquals(res.success, true);
11-
12-
if (res.success) {
13-
assertEquals(res.value, [
14-
{
15-
end: 47,
16-
kind: "email",
17-
start: 19,
18-
text: "no-reply+foo@some.domain.dev",
19-
value: {
20-
email: "no-reply+foo@some.domain.dev",
21-
},
9+
assertEquals(res, [
10+
{
11+
end: 47,
12+
kind: "email",
13+
start: 19,
14+
text: "no-reply+foo@some.domain.dev",
15+
value: {
16+
email: "no-reply+foo@some.domain.dev",
2217
},
23-
]);
24-
}
18+
},
19+
]);
2520
});

0 commit comments

Comments
 (0)