• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wanasit / chrono / 9329503257

01 Jun 2024 10:10AM UTC coverage: 91.432% (+0.01%) from 91.421%
9329503257

push

github

Wanasit Tanakitrungruang
Fix: Failing unittest on UTC timezone

1436 of 1820 branches covered (78.9%)

4418 of 4832 relevant lines covered (91.43%)

400.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.5
/src/common/parsers/AbstractTimeExpressionParser.ts
1
import { Parser, ParsingContext } from "../../chrono";
2
import { ParsingComponents, ParsingResult } from "../../results";
3
import { Meridiem } from "../../types";
90✔
4

5
// prettier-ignore
6
function primaryTimePattern(leftBoundary: string, primaryPrefix: string, primarySuffix: string, flags: string) {
7
    return new RegExp(
104✔
8
            `${leftBoundary}` +
9
            `${primaryPrefix}` +
10
            `(\\d{1,4})` +
11
            `(?:` +
12
                `(?:\\.|:|:)` +
13
                `(\\d{1,2})` +
14
                `(?:` +
15
                    `(?::|:)` +
16
                    `(\\d{2})` +
17
                    `(?:\\.(\\d{1,6}))?` +
18
                `)?` +
19
            `)?` +
20
            `(?:\\s*(a\\.m\\.|p\\.m\\.|am?|pm?))?` +
21
            `${primarySuffix}`,
22
        flags
23
    );
24
}
25

26
// prettier-ignore
27
function followingTimePatten(followingPhase: string, followingSuffix: string) {
28
    return new RegExp(
92✔
29
        `^(${followingPhase})` +
30
            `(\\d{1,4})` +
31
            `(?:` +
32
                `(?:\\.|\\:|\\:)` +
33
                `(\\d{1,2})` +
34
                `(?:` +
35
                    `(?:\\.|\\:|\\:)` +
36
                    `(\\d{1,2})(?:\\.(\\d{1,6}))?` +
37
                `)?` +
38
            `)?` +
39
            `(?:\\s*(a\\.m\\.|p\\.m\\.|am?|pm?))?` +
40
            `${followingSuffix}`,
41
        "i"
42
    );
43
}
44

45
const HOUR_GROUP = 2;
90✔
46
const MINUTE_GROUP = 3;
90✔
47
const SECOND_GROUP = 4;
90✔
48
const MILLI_SECOND_GROUP = 5;
90✔
49
const AM_PM_HOUR_GROUP = 6;
90✔
50

51
export abstract class AbstractTimeExpressionParser implements Parser {
90✔
52
    abstract primaryPrefix(): string;
53
    abstract followingPhase(): string;
54
    strictMode: boolean;
55

56
    constructor(strictMode = false) {
720✔
57
        this.strictMode = strictMode;
1,533✔
58
    }
59

60
    patternFlags(): string {
61
        return "i";
82✔
62
    }
63

64
    primaryPatternLeftBoundary(): string {
65
        return `(^|\\s|T|\\b)`;
82✔
66
    }
67

68
    primarySuffix(): string {
69
        return `(?!/)(?=\\W|$)`;
405✔
70
    }
71

72
    followingSuffix(): string {
73
        return `(?!/)(?=\\W|$)`;
1,248✔
74
    }
75

76
    pattern(context: ParsingContext): RegExp {
77
        return this.getPrimaryTimePatternThroughCache();
1,529✔
78
    }
79

80
    extract(context: ParsingContext, match: RegExpMatchArray): ParsingResult {
81
        const startComponents = this.extractPrimaryTimeComponents(context, match);
1,591✔
82
        if (!startComponents) {
1,591✔
83
            // If the match seem like a year e.g. "2013.12:...",
84
            // then skips the year part and try matching again.
85
            if (match[0].match(/^\d{4}/)) {
343✔
86
                match.index += 4; // Skip over potential overlapping pattern
64✔
87
                return null;
64✔
88
            }
89

90
            match.index += match[0].length; // Skip over potential overlapping pattern
279✔
91
            return null;
279✔
92
        }
93

94
        const index = match.index + match[1].length;
1,248✔
95
        const text = match[0].substring(match[1].length);
1,248✔
96
        const result = context.createParsingResult(index, text, startComponents);
1,248✔
97
        match.index += match[0].length; // Skip over potential overlapping pattern
1,248✔
98

99
        const remainingText = context.text.substring(match.index);
1,248✔
100
        const followingPattern = this.getFollowingTimePatternThroughCache();
1,248✔
101
        const followingMatch = followingPattern.exec(remainingText);
1,248✔
102

103
        // Pattern "456-12", "2022-12" should not be time without proper context
104
        if (text.match(/^\d{3,4}/) && followingMatch) {
1,248✔
105
            // e.g. "2022-12"
106
            if (followingMatch[0].match(/^\s*([+-])\s*\d{2,4}$/)) {
45✔
107
                return null;
33✔
108
            }
109
            // e.g. "2022-12:01..."
110
            if (followingMatch[0].match(/^\s*([+-])\s*\d{2}\W\d{2}/)) {
12✔
111
                return null;
1✔
112
            }
113
        }
114

115
        if (
1,214✔
116
            !followingMatch ||
1,362✔
117
            // Pattern "YY.YY -XXXX" is more like timezone offset
118
            followingMatch[0].match(/^\s*([+-])\s*\d{3,4}$/)
119
        ) {
120
            return this.checkAndReturnWithoutFollowingPattern(result);
1,074✔
121
        }
122

123
        result.end = this.extractFollowingTimeComponents(context, followingMatch, result);
140✔
124
        if (result.end) {
140✔
125
            result.text += followingMatch[0];
123✔
126
        }
127

128
        return this.checkAndReturnWithFollowingPattern(result);
140✔
129
    }
130

131
    extractPrimaryTimeComponents(
132
        context: ParsingContext,
133
        match: RegExpMatchArray,
134
        strict = false
1,502✔
135
    ): null | ParsingComponents {
136
        const components = context.createParsingComponents();
1,502✔
137
        let minute = 0;
1,502✔
138
        let meridiem = null;
1,502✔
139

140
        // ----- Hours
141
        let hour = parseInt(match[HOUR_GROUP]);
1,502✔
142
        if (hour > 100) {
1,502✔
143
            if (this.strictMode || match[MINUTE_GROUP] != null) {
350✔
144
                return null;
38✔
145
            }
146

147
            minute = hour % 100;
312✔
148
            hour = Math.floor(hour / 100);
312✔
149
        }
150

151
        if (hour > 24) {
1,464✔
152
            return null;
170✔
153
        }
154

155
        // ----- Minutes
156
        if (match[MINUTE_GROUP] != null) {
1,294✔
157
            if (match[MINUTE_GROUP].length == 1 && !match[AM_PM_HOUR_GROUP]) {
224✔
158
                // Skip single digit minute e.g. "at 1.1 xx"
159
                return null;
27✔
160
            }
161

162
            minute = parseInt(match[MINUTE_GROUP]);
197✔
163
        }
164

165
        if (minute >= 60) {
1,267✔
166
            return null;
16✔
167
        }
168

169
        if (hour > 12) {
1,251✔
170
            meridiem = Meridiem.PM;
457✔
171
        }
172

173
        // ----- AM & PM
174
        if (match[AM_PM_HOUR_GROUP] != null) {
1,251✔
175
            if (hour > 12) return null;
109✔
176
            const ampm = match[AM_PM_HOUR_GROUP][0].toLowerCase();
106✔
177
            if (ampm == "a") {
106✔
178
                meridiem = Meridiem.AM;
40✔
179
                if (hour == 12) {
40!
180
                    hour = 0;
×
181
                }
182
            }
183

184
            if (ampm == "p") {
106✔
185
                meridiem = Meridiem.PM;
66✔
186
                if (hour != 12) {
66✔
187
                    hour += 12;
61✔
188
                }
189
            }
190
        }
191

192
        components.assign("hour", hour);
1,248✔
193
        components.assign("minute", minute);
1,248✔
194

195
        if (meridiem !== null) {
1,248✔
196
            components.assign("meridiem", meridiem);
560✔
197
        } else {
198
            if (hour < 12) {
688✔
199
                components.imply("meridiem", Meridiem.AM);
610✔
200
            } else {
201
                components.imply("meridiem", Meridiem.PM);
78✔
202
            }
203
        }
204

205
        // ----- Millisecond
206
        if (match[MILLI_SECOND_GROUP] != null) {
1,248✔
207
            const millisecond = parseInt(match[MILLI_SECOND_GROUP].substring(0, 3));
2✔
208
            if (millisecond >= 1000) return null;
2!
209

210
            components.assign("millisecond", millisecond);
2✔
211
        }
212

213
        // ----- Second
214
        if (match[SECOND_GROUP] != null) {
1,248✔
215
            const second = parseInt(match[SECOND_GROUP]);
43✔
216
            if (second >= 60) return null;
43!
217

218
            components.assign("second", second);
43✔
219
        }
220

221
        return components;
1,248✔
222
    }
223

224
    extractFollowingTimeComponents(
225
        context: ParsingContext,
226
        match: RegExpMatchArray,
227
        result: ParsingResult
228
    ): null | ParsingComponents {
229
        const components = context.createParsingComponents();
140✔
230

231
        // ----- Millisecond
232
        if (match[MILLI_SECOND_GROUP] != null) {
140!
233
            const millisecond = parseInt(match[MILLI_SECOND_GROUP].substring(0, 3));
×
234
            if (millisecond >= 1000) return null;
×
235

236
            components.assign("millisecond", millisecond);
×
237
        }
238

239
        // ----- Second
240
        if (match[SECOND_GROUP] != null) {
140✔
241
            const second = parseInt(match[SECOND_GROUP]);
7✔
242
            if (second >= 60) return null;
7!
243

244
            components.assign("second", second);
7✔
245
        }
246

247
        let hour = parseInt(match[HOUR_GROUP]);
140✔
248
        let minute = 0;
140✔
249
        let meridiem = -1;
140✔
250

251
        // ----- Minute
252
        if (match[MINUTE_GROUP] != null) {
140✔
253
            minute = parseInt(match[MINUTE_GROUP]);
40✔
254
        } else if (hour > 100) {
100✔
255
            minute = hour % 100;
12✔
256
            hour = Math.floor(hour / 100);
12✔
257
        }
258

259
        if (minute >= 60 || hour > 24) {
140✔
260
            return null;
17✔
261
        }
262

263
        if (hour >= 12) {
123✔
264
            meridiem = Meridiem.PM;
57✔
265
        }
266

267
        // ----- AM & PM
268
        if (match[AM_PM_HOUR_GROUP] != null) {
123✔
269
            if (hour > 12) {
33!
270
                return null;
×
271
            }
272

273
            const ampm = match[AM_PM_HOUR_GROUP][0].toLowerCase();
33✔
274
            if (ampm == "a") {
33✔
275
                meridiem = Meridiem.AM;
13✔
276
                if (hour == 12) {
13✔
277
                    hour = 0;
1✔
278
                    if (!components.isCertain("day")) {
1✔
279
                        components.imply("day", components.get("day") + 1);
1✔
280
                    }
281
                }
282
            }
283

284
            if (ampm == "p") {
33✔
285
                meridiem = Meridiem.PM;
20✔
286
                if (hour != 12) hour += 12;
20✔
287
            }
288

289
            if (!result.start.isCertain("meridiem")) {
33✔
290
                if (meridiem == Meridiem.AM) {
18✔
291
                    result.start.imply("meridiem", Meridiem.AM);
7✔
292

293
                    if (result.start.get("hour") == 12) {
7✔
294
                        result.start.assign("hour", 0);
1✔
295
                    }
296
                } else {
297
                    result.start.imply("meridiem", Meridiem.PM);
11✔
298

299
                    if (result.start.get("hour") != 12) {
11✔
300
                        result.start.assign("hour", result.start.get("hour") + 12);
10✔
301
                    }
302
                }
303
            }
304
        }
305

306
        components.assign("hour", hour);
123✔
307
        components.assign("minute", minute);
123✔
308

309
        if (meridiem >= 0) {
123✔
310
            components.assign("meridiem", meridiem);
87✔
311
        } else {
312
            const startAtPM = result.start.isCertain("meridiem") && result.start.get("hour") > 12;
36✔
313
            if (startAtPM) {
36✔
314
                if (result.start.get("hour") - 12 > hour) {
8✔
315
                    // 10pm - 1 (am)
316
                    components.imply("meridiem", Meridiem.AM);
4✔
317
                } else if (hour <= 12) {
4✔
318
                    components.assign("hour", hour + 12);
4✔
319
                    components.assign("meridiem", Meridiem.PM);
4✔
320
                }
321
            } else if (hour > 12) {
28!
322
                components.imply("meridiem", Meridiem.PM);
×
323
            } else if (hour <= 12) {
28✔
324
                components.imply("meridiem", Meridiem.AM);
28✔
325
            }
326
        }
327

328
        if (components.date().getTime() < result.start.date().getTime()) {
123✔
329
            components.imply("day", components.get("day") + 1);
14✔
330
        }
331

332
        return components;
123✔
333
    }
334

335
    private checkAndReturnWithoutFollowingPattern(result) {
336
        // Single digit (e.g "1") should not be counted as time expression (without proper context)
337
        if (result.text.match(/^\d$/)) {
1,074✔
338
            return null;
230✔
339
        }
340

341
        // Three or more digit (e.g. "203", "2014") should not be counted as time expression (without proper context)
342
        if (result.text.match(/^\d\d\d+$/)) {
844✔
343
            return null;
233✔
344
        }
345

346
        // Instead of "am/pm", it ends with "a" or "p" (e.g "1a", "123p"), this seems unlikely
347
        if (result.text.match(/\d[apAP]$/)) {
611✔
348
            return null;
3✔
349
        }
350

351
        // If it ends only with numbers or dots
352
        const endingWithNumbers = result.text.match(/[^\d:.](\d[\d.]+)$/);
608✔
353
        if (endingWithNumbers) {
608✔
354
            const endingNumbers: string = endingWithNumbers[1];
36✔
355

356
            // In strict mode (e.g. "at 1" or "at 1.2"), this should not be accepted
357
            if (this.strictMode) {
36✔
358
                return null;
3✔
359
            }
360

361
            // If it ends only with dot single digit, e.g. "at 1.2"
362
            if (endingNumbers.includes(".") && !endingNumbers.match(/\d(\.\d{2})+$/)) {
33!
363
                return null;
×
364
            }
365

366
            // If it ends only with numbers above 24, e.g. "at 25"
367
            const endingNumberVal = parseInt(endingNumbers);
33✔
368
            if (endingNumberVal > 24) {
33✔
369
                return null;
7✔
370
            }
371
        }
372

373
        return result;
598✔
374
    }
375

376
    private checkAndReturnWithFollowingPattern(result) {
377
        if (result.text.match(/^\d+-\d+$/)) {
140✔
378
            return null;
34✔
379
        }
380

381
        // If it ends only with numbers or dots
382
        const endingWithNumbers = result.text.match(/[^\d:.](\d[\d.]+)\s*-\s*(\d[\d.]+)$/);
106✔
383
        if (endingWithNumbers) {
106✔
384
            // In strict mode (e.g. "at 1-3" or "at 1.2 - 2.3"), this should not be accepted
385
            if (this.strictMode) {
9✔
386
                return null;
6✔
387
            }
388

389
            const startingNumbers: string = endingWithNumbers[1];
3✔
390
            const endingNumbers: string = endingWithNumbers[2];
3✔
391
            // If it ends only with dot single digit, e.g. "at 1.2"
392
            if (endingNumbers.includes(".") && !endingNumbers.match(/\d(\.\d{2})+$/)) {
3✔
393
                return null;
3✔
394
            }
395

396
            // If it ends only with numbers above 24, e.g. "at 25"
397
            const endingNumberVal = parseInt(endingNumbers);
×
398
            const startingNumberVal = parseInt(startingNumbers);
×
399
            if (endingNumberVal > 24 || startingNumberVal > 24) {
×
400
                return null;
×
401
            }
402
        }
403

404
        return result;
97✔
405
    }
406

407
    private cachedPrimaryPrefix = null;
1,533✔
408
    private cachedPrimarySuffix = null;
1,533✔
409
    private cachedPrimaryTimePattern = null;
1,533✔
410

411
    getPrimaryTimePatternThroughCache() {
412
        const primaryPrefix = this.primaryPrefix();
1,529✔
413
        const primarySuffix = this.primarySuffix();
1,529✔
414

415
        if (this.cachedPrimaryPrefix === primaryPrefix && this.cachedPrimarySuffix === primarySuffix) {
1,529✔
416
            return this.cachedPrimaryTimePattern;
1,425✔
417
        }
418

419
        this.cachedPrimaryTimePattern = primaryTimePattern(
104✔
420
            this.primaryPatternLeftBoundary(),
421
            primaryPrefix,
422
            primarySuffix,
423
            this.patternFlags()
424
        );
425
        this.cachedPrimaryPrefix = primaryPrefix;
104✔
426
        this.cachedPrimarySuffix = primarySuffix;
104✔
427
        return this.cachedPrimaryTimePattern;
104✔
428
    }
429

430
    private cachedFollowingPhase = null;
1,533✔
431
    private cachedFollowingSuffix = null;
1,533✔
432
    private cachedFollowingTimePatten = null;
1,533✔
433

434
    getFollowingTimePatternThroughCache() {
435
        const followingPhase = this.followingPhase();
1,248✔
436
        const followingSuffix = this.followingSuffix();
1,248✔
437

438
        if (this.cachedFollowingPhase === followingPhase && this.cachedFollowingSuffix === followingSuffix) {
1,248✔
439
            return this.cachedFollowingTimePatten;
1,156✔
440
        }
441

442
        this.cachedFollowingTimePatten = followingTimePatten(followingPhase, followingSuffix);
92✔
443
        this.cachedFollowingPhase = followingPhase;
92✔
444
        this.cachedFollowingSuffix = followingSuffix;
92✔
445
        return this.cachedFollowingTimePatten;
92✔
446
    }
447
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc