From c8d1d610fc68bacb084339ffaa45d81e8c3b9fa4 Mon Sep 17 00:00:00 2001 From: Jayesh Bhade <52350067+Jaybhade@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:55:22 +0530 Subject: [PATCH] fix(zh): don't consume a leading Chinese numeral as a word boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inherited patternLeftBoundary() is `(\W|^)`, and CJK characters satisfy `\W`. When a Chinese expression starts at the very beginning of the text, the alternation prefers `\W`, consumes the first character, and the parser then matches a shorter expression with a different value: 十一月十日 (Nov 10) -> 一月十日 (Jan 10) 十二月十日 (Dec 10) -> 二月十日 (Feb 10) 二十天前 (20 days ago) -> 十天前 (10 days ago) 十五天前 (15 days ago) -> 五天前 (5 days ago) 二十天後 (in 20 days) -> 十天後 (in 10 days) These are silent wrong answers rather than parse failures. The same expressions resolve correctly once they are preceded by a space or embedded in a sentence, which is why this went unnoticed. ZHHansTimeExpressionParser and ZHHantTimeExpressionParser already avoid this by returning an empty capturing group from patternLeftBoundary(). Apply that same override to the date, ago and deadline parsers, keeping zh.hans and zh.hant symmetric. --- .../zh/hans/parsers/ZHHansAgoFormatParser.ts | 6 +++ .../zh/hans/parsers/ZHHansDateParser.ts | 6 +++ .../parsers/ZHHansDeadlineFormatParser.ts | 6 +++ .../zh/hant/parsers/ZHHantAgoFormatParser.ts | 6 +++ .../zh/hant/parsers/ZHHantDateParser.ts | 6 +++ .../parsers/ZHHantDeadlineFormatParser.ts | 6 +++ test/zh/hans/zh_hans_ago.test.ts | 39 ++++++++++++++++++ test/zh/hans/zh_hans_date.test.ts | 40 +++++++++++++++++++ test/zh/hans/zh_hans_deadline.test.ts | 30 ++++++++++++++ test/zh/hant/zh_hant_ago.test.ts | 39 ++++++++++++++++++ test/zh/hant/zh_hant_date.test.ts | 40 +++++++++++++++++++ test/zh/hant/zh_hant_deadline.test.ts | 30 ++++++++++++++ 12 files changed, 254 insertions(+) diff --git a/src/locales/zh/hans/parsers/ZHHansAgoFormatParser.ts b/src/locales/zh/hans/parsers/ZHHansAgoFormatParser.ts index 574d7c144..96baa89ba 100644 --- a/src/locales/zh/hans/parsers/ZHHansAgoFormatParser.ts +++ b/src/locales/zh/hans/parsers/ZHHansAgoFormatParser.ts @@ -17,6 +17,12 @@ const NUMBER_GROUP = 1; const UNIT_GROUP = 2; export default class ZHHansAgoFormatParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern(): RegExp { return PATTERN; } diff --git a/src/locales/zh/hans/parsers/ZHHansDateParser.ts b/src/locales/zh/hans/parsers/ZHHansDateParser.ts index bd4676b8c..0f431e9f8 100644 --- a/src/locales/zh/hans/parsers/ZHHansDateParser.ts +++ b/src/locales/zh/hans/parsers/ZHHansDateParser.ts @@ -7,6 +7,12 @@ const MONTH_GROUP = 2; const DAY_GROUP = 3; export default class ZHHansDateParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern() { // prettier-ignore return new RegExp( diff --git a/src/locales/zh/hans/parsers/ZHHansDeadlineFormatParser.ts b/src/locales/zh/hans/parsers/ZHHansDeadlineFormatParser.ts index fb0924153..c9d861e65 100644 --- a/src/locales/zh/hans/parsers/ZHHansDeadlineFormatParser.ts +++ b/src/locales/zh/hans/parsers/ZHHansDeadlineFormatParser.ts @@ -17,6 +17,12 @@ const NUMBER_GROUP = 1; const UNIT_GROUP = 2; export default class ZHHansDeadlineFormatParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern(): RegExp { return PATTERN; } diff --git a/src/locales/zh/hant/parsers/ZHHantAgoFormatParser.ts b/src/locales/zh/hant/parsers/ZHHantAgoFormatParser.ts index 8ff6c0fd0..afad492bf 100644 --- a/src/locales/zh/hant/parsers/ZHHantAgoFormatParser.ts +++ b/src/locales/zh/hant/parsers/ZHHantAgoFormatParser.ts @@ -17,6 +17,12 @@ const NUMBER_GROUP = 1; const UNIT_GROUP = 2; export default class ZHHantAgoFormatParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern(): RegExp { return PATTERN; } diff --git a/src/locales/zh/hant/parsers/ZHHantDateParser.ts b/src/locales/zh/hant/parsers/ZHHantDateParser.ts index f12a31ee7..5f0785442 100644 --- a/src/locales/zh/hant/parsers/ZHHantDateParser.ts +++ b/src/locales/zh/hant/parsers/ZHHantDateParser.ts @@ -7,6 +7,12 @@ const MONTH_GROUP = 2; const DAY_GROUP = 3; export default class ZHHantDateParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern() { // prettier-ignore return new RegExp( diff --git a/src/locales/zh/hant/parsers/ZHHantDeadlineFormatParser.ts b/src/locales/zh/hant/parsers/ZHHantDeadlineFormatParser.ts index ccb317dc5..3e4b9ab91 100644 --- a/src/locales/zh/hant/parsers/ZHHantDeadlineFormatParser.ts +++ b/src/locales/zh/hant/parsers/ZHHantDeadlineFormatParser.ts @@ -17,6 +17,12 @@ const NUMBER_GROUP = 1; const UNIT_GROUP = 2; export default class ZHHantDeadlineFormatParser extends AbstractParserWithWordBoundaryChecking { + patternLeftBoundary(): string { + // Return a capturing group to ensure that the match index is correct in the base class + // while avoiding matching CJK characters as word boundaries. + return "()"; + } + innerPattern(): RegExp { return PATTERN; } diff --git a/test/zh/hans/zh_hans_ago.test.ts b/test/zh/hans/zh_hans_ago.test.ts index 114be5e40..629f06375 100644 --- a/test/zh/hans/zh_hans_ago.test.ts +++ b/test/zh/hans/zh_hans_ago.test.ts @@ -56,3 +56,42 @@ test("Test - Simplified Chinese Ago Expression", function () { expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); }); }); + +test("Test - Ago expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hans, "十五天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十五天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 26, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "二十天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("二十天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 21, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "三十分钟前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("三十分钟前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 10, 11, 44); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "十天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 31, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); +}); diff --git a/test/zh/hans/zh_hans_date.test.ts b/test/zh/hans/zh_hans_date.test.ts index 093619d67..70161fb9b 100644 --- a/test/zh/hans/zh_hans_date.test.ts +++ b/test/zh/hans/zh_hans_date.test.ts @@ -114,3 +114,43 @@ test("Test - Range Expression", function () { } }); }); + +test("Test - Date expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hans, "十一月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十一月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(10); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 11 - 1, 10, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "十二月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十二月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(12); + expect(result.start.get("day")).toBe(10); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 12 - 1, 10, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "十月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("month")).toBe(10); + expect(result.start.get("day")).toBe(10); + }); +}); diff --git a/test/zh/hans/zh_hans_deadline.test.ts b/test/zh/hans/zh_hans_deadline.test.ts index 8fa292d60..92618e17e 100644 --- a/test/zh/hans/zh_hans_deadline.test.ts +++ b/test/zh/hans/zh_hans_deadline.test.ts @@ -173,3 +173,33 @@ test("Test - Untested suffix", function () { expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); }); }); + +test("Test - Deadline expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hans, "十五天后", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十五天后"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 25, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "二十天后", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("二十天后"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 30, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hans, "十天后", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十天后"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 20, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); +}); diff --git a/test/zh/hant/zh_hant_ago.test.ts b/test/zh/hant/zh_hant_ago.test.ts index d340bafce..f0e065c38 100644 --- a/test/zh/hant/zh_hant_ago.test.ts +++ b/test/zh/hant/zh_hant_ago.test.ts @@ -56,3 +56,42 @@ test("Test - Traditional Chinese Ago Expression", function () { expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); }); }); + +test("Test - Ago expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hant, "十五天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十五天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 26, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "二十天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("二十天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 21, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "三十分鐘前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("三十分鐘前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 10, 11, 44); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "十天前", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十天前"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 6, 31, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); +}); diff --git a/test/zh/hant/zh_hant_date.test.ts b/test/zh/hant/zh_hant_date.test.ts index 735892e08..78e55495a 100644 --- a/test/zh/hant/zh_hant_date.test.ts +++ b/test/zh/hant/zh_hant_date.test.ts @@ -114,3 +114,43 @@ test("Test - Range Expression", function () { } }); }); + +test("Test - Date expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hant, "十一月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十一月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(11); + expect(result.start.get("day")).toBe(10); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 11 - 1, 10, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "十二月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十二月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("year")).toBe(2012); + expect(result.start.get("month")).toBe(12); + expect(result.start.get("day")).toBe(10); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 12 - 1, 10, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "十月十日", new Date(2012, 8 - 1, 10), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十月十日"); + + expect(result.start).not.toBeNull(); + expect(result.start.get("month")).toBe(10); + expect(result.start.get("day")).toBe(10); + }); +}); diff --git a/test/zh/hant/zh_hant_deadline.test.ts b/test/zh/hant/zh_hant_deadline.test.ts index 9b2cfec07..5fe53cfe8 100644 --- a/test/zh/hant/zh_hant_deadline.test.ts +++ b/test/zh/hant/zh_hant_deadline.test.ts @@ -134,3 +134,33 @@ test("Test - Single Expression", function () { expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); }); }); + +test("Test - Deadline expression at the beginning of the text", function () { + // The leading Chinese numeral must not be consumed as a word boundary. + testSingleCase(chrono.zh.hant, "十五天後", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十五天後"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 25, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "二十天後", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("二十天後"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 30, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); + + testSingleCase(chrono.zh.hant, "十天後", new Date(2012, 7, 10, 12, 14), (result) => { + expect(result.index).toBe(0); + expect(result.text).toBe("十天後"); + + const resultDate = result.start.date(); + const expectDate = new Date(2012, 7, 20, 12); + expect(expectDate.getTime()).toBeCloseTo(resultDate.getTime()); + }); +});