Skip to content

Commit 07dc5c9

Browse files
committed
fix: backtrack on invalid Date construction in Time parser
1 parent d33426d commit 07dc5c9

5 files changed

Lines changed: 141 additions & 14 deletions

File tree

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.3.0",
3+
"version": "0.3.1",
44
"exports": {
55
".": "./mod.ts"
66
},

src/Time.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import type { Parser } from "@claudiu-ceia/combine";
1919
import { __, dot, nonWord } from "./common.ts";
2020
import { ent } from "./Entity.ts";
21+
import { safe } from "./guard.ts";
2122
import type { Entity } from "./Entity.ts";
2223
import { fuzzyCase } from "./parsers.ts";
2324
import { Quantity, type QuantityEntity } from "./Quantity.ts";
@@ -376,7 +377,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
376377
return any(str("/"), str(" "), str("-"), str("."));
377378
},
378379
PartialDateMonthYear(s) {
379-
return map(
380+
return safe(map(
380381
any(
381382
seq(s.NumericMonth, s.DateSeparator, s.Year),
382383
seq(s.LiteralMonth, s.DateSeparator, s.Year),
@@ -393,7 +394,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
393394
a,
394395
);
395396
},
396-
);
397+
), "valid date");
397398
},
398399
QualifiedDay(s) {
399400
return map(
@@ -434,7 +435,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
434435
);
435436
},
436437
PartialDateDayMonth(s) {
437-
return map(
438+
return safe(map(
438439
seq(s.QualifiedDay, optional(__(str("of"))), s.LiteralMonth),
439440
([day, _of, month], b, a) => {
440441
const year = new Date().getFullYear();
@@ -447,28 +448,28 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
447448
a,
448449
);
449450
},
450-
);
451+
), "valid date");
451452
},
452453
FullDate(s) {
453454
return any(
454-
map(
455+
safe(map(
455456
seq(s.PartialDateDayMonth, space(), s.Year),
456457
([partialDayMonth, _sp, year], b, a) => {
457458
const original = partialDayMonth.value.when;
458459
if (typeof original !== "string") {
459460
throw new Error(`
460-
Unexpected partial date match:
461-
${JSON.stringify(partialDayMonth)}
462-
`);
461+
Unexpected partial date match:
462+
${JSON.stringify(partialDayMonth)}
463+
`);
463464
}
464465

465466
const date = new Date(original);
466467
date.setFullYear(year);
467468

468469
return time({ when: date.toISOString(), grain: "day" }, b, a);
469470
},
470-
),
471-
map(
471+
), "valid date"),
472+
safe(map(
472473
any(
473474
seq(
474475
s.Day,
@@ -509,7 +510,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
509510
a,
510511
);
511512
},
512-
),
513+
), "valid date"),
513514
);
514515
},
515516
PartialDateMonthYearEra(s) {
@@ -581,7 +582,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
581582
s.GrainQuantity,
582583
s.UnspecifiedGrainAmount,
583584
s.YearEra,
584-
map(s.LiteralMonth, (month, b, a) => {
585+
safe(map(s.LiteralMonth, (month, b, a) => {
585586
const year = new Date().getFullYear();
586587
return time(
587588
{
@@ -591,7 +592,7 @@ export const Time: TimeLanguage = createLanguage<TimeLanguage>({
591592
b,
592593
a,
593594
);
594-
}),
595+
}), "valid date"),
595596
),
596597
);
597598
},

src/guard.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ export const guard = <T>(
1212
return pred(res.value) ? res : failure(ctx, expected);
1313
};
1414
};
15+
16+
// Catch exceptions thrown during parsing and convert them to failures.
17+
export const safe = <T>(
18+
p: Parser<T>,
19+
expected = "safe",
20+
): Parser<T> => {
21+
return (ctx) => {
22+
try {
23+
return p(ctx);
24+
} catch {
25+
return failure(ctx, expected);
26+
}
27+
};
28+
};

tests/Time.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,42 @@ Deno.test("Circa time", () => {
411411
},
412412
]);
413413
});
414+
Deno.test("FullDate: invalid date backtracks instead of throwing", () => {
415+
// 31st of February is not a real date — should not throw
416+
const res = Duckling([Time.parser]).extract("On 31/02/2024 something happened");
417+
// Should not contain an entity for this invalid date
418+
for (const entity of res) {
419+
if (entity.kind === "time" && typeof entity.value.when === "string") {
420+
assertEquals(entity.value.when !== "Invalid Date", true,
421+
`Should not produce Invalid Date, got entity: ${JSON.stringify(entity)}`);
422+
}
423+
}
424+
});
425+
426+
Deno.test("FullDate: valid date still parses correctly", () => {
427+
const res = Duckling([Time.parser]).extract("On 15/06/2024 we met.");
428+
const dates = res.filter((e) => e.kind === "time");
429+
assertEquals(dates.length >= 1, true, "Should find at least one time entity");
430+
const date = dates[0];
431+
assertEquals(typeof date.value.when, "string");
432+
assertEquals((date.value.when as string).includes("Invalid"), false);
433+
});
434+
435+
Deno.test("FullDate: does not crash on nonsense date-like input", () => {
436+
// Should not throw, regardless of what entities are produced
437+
const inputs = [
438+
"99/99/9999 is not a date",
439+
"Meeting on 32-13-2025 maybe",
440+
"Date: 00.00.0000 test",
441+
];
442+
for (const input of inputs) {
443+
const res = Duckling([Time.parser]).extract(input);
444+
// Just verify it doesn't throw and doesn't produce "Invalid Date"
445+
for (const entity of res) {
446+
if (entity.kind === "time" && typeof entity.value.when === "string") {
447+
assertEquals(entity.value.when !== "Invalid Date", true,
448+
`Should not produce Invalid Date for "${input}", got: ${JSON.stringify(entity)}`);
449+
}
450+
}
451+
}
452+
});

tests/guard.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { assertEquals } from "@std/assert";
2+
import { map, str } from "@claudiu-ceia/combine";
3+
import { guard, safe } from "../src/guard.ts";
4+
5+
Deno.test("guard: passes when predicate returns true", () => {
6+
const p = guard(str("abc"), (v) => v === "abc", "expected abc");
7+
const res = p({ text: "abc", index: 0 });
8+
assertEquals(res.success, true);
9+
if (res.success) assertEquals(res.value, "abc");
10+
});
11+
12+
Deno.test("guard: fails when predicate returns false", () => {
13+
const p = guard(str("abc"), () => false, "rejected");
14+
const res = p({ text: "abc", index: 0 });
15+
assertEquals(res.success, false);
16+
if (!res.success) assertEquals(res.expected, "rejected");
17+
});
18+
19+
Deno.test("guard: propagates inner parser failure", () => {
20+
const p = guard(str("abc"), () => true, "guard");
21+
const res = p({ text: "xyz", index: 0 });
22+
assertEquals(res.success, false);
23+
});
24+
25+
Deno.test("guard: does not consume input on predicate failure", () => {
26+
const p = guard(str("abc"), () => false, "rejected");
27+
const res = p({ text: "abcdef", index: 0 });
28+
assertEquals(res.success, false);
29+
if (!res.success) assertEquals(res.ctx.index, 0);
30+
});
31+
32+
Deno.test("safe: passes through successful parse", () => {
33+
const p = safe(str("ok"), "safe");
34+
const res = p({ text: "ok", index: 0 });
35+
assertEquals(res.success, true);
36+
if (res.success) assertEquals(res.value, "ok");
37+
});
38+
39+
Deno.test("safe: passes through normal parse failure", () => {
40+
const p = safe(str("ok"), "safe");
41+
const res = p({ text: "no", index: 0 });
42+
assertEquals(res.success, false);
43+
});
44+
45+
Deno.test("safe: catches thrown exception and returns failure", () => {
46+
const throwing = map(str("boom"), () => {
47+
throw new Error("kaboom");
48+
});
49+
const p = safe(throwing, "caught");
50+
const res = p({ text: "boom", index: 0 });
51+
assertEquals(res.success, false);
52+
if (!res.success) assertEquals(res.expected, "caught");
53+
});
54+
55+
Deno.test("safe: does not consume input when exception is caught", () => {
56+
const throwing = map(str("boom"), () => {
57+
throw new Error("kaboom");
58+
});
59+
const p = safe(throwing, "caught");
60+
const res = p({ text: "boomstuff", index: 0 });
61+
assertEquals(res.success, false);
62+
if (!res.success) assertEquals(res.ctx.index, 0);
63+
});
64+
65+
Deno.test("safe: catches invalid Date toISOString", () => {
66+
const dateParser = map(str("bad-date"), () => {
67+
return new Date("not-a-date").toISOString();
68+
});
69+
const p = safe(dateParser, "valid date");
70+
const res = p({ text: "bad-date", index: 0 });
71+
assertEquals(res.success, false);
72+
if (!res.success) assertEquals(res.expected, "valid date");
73+
});

0 commit comments

Comments
 (0)