diff --git a/.changeset/add-parse-time-string.md b/.changeset/add-parse-time-string.md new file mode 100644 index 0000000..ce3fef3 --- /dev/null +++ b/.changeset/add-parse-time-string.md @@ -0,0 +1,5 @@ +--- +"@taskade/temporal-parser": minor +--- + +Add `parseTimeString` function for parsing standalone time strings in multiple formats (12h/24h, AM/PM, locale variations). Returns `TimeAst` compatible with Temporal.PlainTime. Handles common LLM-generated time formats like "09:00" alongside locale formats like "9:07 AM". Features LLM-friendly error messages with examples and suggestions. diff --git a/README.md b/README.md index af62f0e..548f1d1 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,39 @@ const offset = parseOffset('+08:00'); // { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' } ``` +### Standalone Time Parser + +```typescript +import { parseTimeString } from '@taskade/temporal-parser'; + +// Parse 12-hour format with AM/PM +const time1 = parseTimeString('2:30 PM'); +// { kind: 'Time', hour: 14, minute: 30 } + +// Parse 24-hour format (international) +const time2 = parseTimeString('14:30'); +// { kind: 'Time', hour: 14, minute: 30 } + +// Parse with seconds +const time3 = parseTimeString('2:30:45 PM'); +// { kind: 'Time', hour: 14, minute: 30, second: 45 } + +// Parse with fractional seconds +const time4 = parseTimeString('14:30:45.123'); +// { kind: 'Time', hour: 14, minute: 30, second: 45, fraction: '123' } + +// Flexible AM/PM formats +parseTimeString('2:30 PM'); // Standard +parseTimeString('2:30PM'); // No space +parseTimeString('2:30 pm'); // Lowercase +parseTimeString('2:30 p.m.'); // With periods + +// Special times +parseTimeString('12:00 AM'); // Midnight (hour: 0) +parseTimeString('12:00 PM'); // Noon (hour: 12) +parseTimeString('11:59 PM'); // End of day (hour: 23) +``` + ### Stringify AST Back to String ```typescript @@ -204,6 +237,26 @@ Parses a numeric timezone offset string. - Hours: 0-14 (UTC-12:00 to UTC+14:00) - Minutes: 0-59 +### `parseTimeString(input: string): TimeAst` + +Parses a standalone time string in various formats. + +**Supported formats:** +- 12-hour with AM/PM: `2:30 PM`, `02:30PM`, `2:30 p.m.` +- 24-hour (international): `14:30`, `02:30`, `23:59` +- With seconds: `2:30:45 PM`, `14:30:45` +- With fractional seconds: `2:30:45.123 PM`, `14:30:45,123` (comma or dot) + +**Special cases:** +- `12:00 AM` → midnight (hour: 0) +- `12:00 PM` → noon (hour: 12) +- `12:30 AM` → 00:30 (after midnight) +- `12:30 PM` → 12:30 (after noon) + +**Returns:** `TimeAst` object compatible with `Temporal.PlainTime.from()` + +**Throws:** `ParseError` if the input is invalid + ### `stringifyTemporal(ast: TemporalAst): string` Converts a temporal AST back to its string representation. diff --git a/src/index.ts b/src/index.ts index 03b7cc5..c5bef77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,9 @@ export { parseTemporal } from './parser.js'; // Export offset parser (useful standalone utility) export { parseOffset } from './parseOffset.js'; +// Export time string parser (useful standalone utility) +export { parseTimeString } from './parseTimeString.js'; + // Export stringify functionality export { stringifyAnnotation, diff --git a/src/parseTimeString.test.ts b/src/parseTimeString.test.ts new file mode 100644 index 0000000..99d7b48 --- /dev/null +++ b/src/parseTimeString.test.ts @@ -0,0 +1,699 @@ +// parseTimeString.test.ts +import { describe, expect, it } from 'vitest'; + +import { ParseError } from './errors.js'; +import { parseTimeString } from './parseTimeString.js'; + +describe('parseTimeString', () => { + describe('24-hour format', () => { + it('should parse basic 24-hour time', () => { + const result = parseTimeString('14:30'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse midnight (00:00)', () => { + const result = parseTimeString('00:00'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse noon (12:00)', () => { + const result = parseTimeString('12:00'); + expect(result).toEqual({ + kind: 'Time', + hour: 12, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse end of day (23:59)', () => { + const result = parseTimeString('23:59'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 59, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse with leading zeros', () => { + const result = parseTimeString('02:05'); + expect(result).toEqual({ + kind: 'Time', + hour: 2, + minute: 5, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse with seconds', () => { + const result = parseTimeString('14:30:45'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: undefined, + }); + }); + + it('should parse with fractional seconds (dot separator)', () => { + const result = parseTimeString('14:30:45.123'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse with fractional seconds (comma separator)', () => { + const result = parseTimeString('14:30:45,123'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse with high precision fractional seconds', () => { + const result = parseTimeString('14:30:45.123456789'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123456789', + }); + }); + }); + + describe('12-hour format with AM/PM', () => { + it('should parse AM time', () => { + const result = parseTimeString('2:30 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 2, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse PM time', () => { + const result = parseTimeString('2:30 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse midnight (12:00 AM)', () => { + const result = parseTimeString('12:00 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse noon (12:00 PM)', () => { + const result = parseTimeString('12:00 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 12, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 12:30 AM (after midnight)', () => { + const result = parseTimeString('12:30 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 12:30 PM (after noon)', () => { + const result = parseTimeString('12:30 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 12, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 11:59 PM (end of day)', () => { + const result = parseTimeString('11:59 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 59, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse lowercase am/pm', () => { + const result = parseTimeString('2:30 pm'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse without space before AM/PM', () => { + const result = parseTimeString('2:30PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse with periods (a.m./p.m.)', () => { + const result = parseTimeString('2:30 p.m.'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse with seconds and AM/PM', () => { + const result = parseTimeString('2:30:45 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: undefined, + }); + }); + + it('should parse with fractional seconds and AM/PM', () => { + const result = parseTimeString('2:30:45.123 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '123', + }); + }); + + it('should parse single digit hour', () => { + const result = parseTimeString('9:00 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse double digit hour', () => { + const result = parseTimeString('10:00 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 10, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + }); + + describe('whitespace handling', () => { + it('should handle leading whitespace', () => { + const result = parseTimeString(' 14:30'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should handle trailing whitespace', () => { + const result = parseTimeString('14:30 '); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should handle whitespace around AM/PM', () => { + const result = parseTimeString('2:30 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + }); + + describe('error handling with LLM-friendly messages', () => { + it('should throw on empty string with helpful message', () => { + try { + parseTimeString(''); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Empty time string. Expected format: "9:07 AM", "09:00", or "14:30" at token index 0', + ); + console.log('Empty string error:', message); + } + }); + + it('should throw on whitespace only with helpful message', () => { + try { + parseTimeString(' '); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Empty time string. Expected format: "9:07 AM", "09:00", or "14:30" at token index 0', + ); + console.log('Whitespace error:', message); + } + }); + + it('should throw on invalid format with examples', () => { + try { + parseTimeString('not a time'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: "not a time". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM") at token index 0', + ); + console.log('Invalid format error:', message); + } + }); + + it('should throw on missing colon with format hint', () => { + try { + parseTimeString('1430'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: "1430". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM") at token index 1', + ); + console.log('Missing colon error:', message); + } + }); + + it('should throw on invalid hour in 24-hour format with helpful message', () => { + try { + parseTimeString('24:00'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid hour for 24-hour format: 24. Hours must be between 0-23 (e.g., "09:00", "14:30"). For hours > 12, use 24-hour format or add AM/PM at token index 4', + ); + console.log('Invalid 24h hour error:', message); + } + }); + + it('should throw on negative hour with helpful message', () => { + try { + parseTimeString('-1:00'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: "-1:00". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM") at token index 0', + ); + console.log('Negative hour error:', message); + } + }); + + it('should throw on invalid hour in 12-hour format (0) with examples', () => { + try { + parseTimeString('0:00 AM'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid hour for 12-hour format: 0. Hours with AM/PM must be between 1-12 (e.g., "9:07 AM", not "0:07 AM" or "13:07 AM") at token index 5', + ); + console.log('Invalid 12h hour (0) error:', message); + } + }); + + it('should throw on hour > 12 in 12-hour format with helpful message', () => { + try { + parseTimeString('13:00 AM'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid hour for 12-hour format: 13. Hours with AM/PM must be between 1-12 (e.g., "9:07 AM", not "0:07 AM" or "13:07 AM") at token index 5', + ); + console.log('Invalid 12h hour (13) error:', message); + } + }); + + it('should throw on invalid minute with range', () => { + try { + parseTimeString('12:60'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe('Invalid minute: 60. Minutes must be between 00-59 at token index 3'); + console.log('Invalid minute error:', message); + } + }); + + it('should throw on negative minute with helpful message', () => { + try { + parseTimeString('12:-1'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: "12:-1". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM") at token index 2', + ); + console.log('Negative minute error:', message); + } + }); + + it('should throw on invalid second with range', () => { + try { + parseTimeString('12:00:60'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe('Invalid second: 60. Seconds must be between 00-59 at token index 5'); + console.log('Invalid second error:', message); + } + }); + + it('should throw on single digit minute with format hint', () => { + try { + parseTimeString('12:5'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: "12:5". Minutes must be 2 digits (e.g., "9:07" not "9:7") at token index 3', + ); + console.log('Single digit minute error:', message); + } + }); + + it('should throw on three digit hour with helpful message', () => { + try { + parseTimeString('123:00'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid hour for 24-hour format: 123. Hours must be between 0-23 (e.g., "09:00", "14:30"). For hours > 12, use 24-hour format or add AM/PM at token index 4', + ); + console.log('Three digit hour error:', message); + } + }); + + it('should throw on invalid AM/PM marker with helpful message', () => { + try { + parseTimeString('9:00 XM'); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + const message = (e as ParseError).message; + expect(message).toBe( + 'Invalid time format: unexpected "XM". Use AM/PM for 12-hour format or omit for 24-hour format at token index 4', + ); + console.log('Invalid AM/PM marker error:', message); + } + }); + }); + + describe('edge cases', () => { + it('should parse 1:00 AM (first hour after midnight)', () => { + const result = parseTimeString('1:00 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 1, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 1:00 PM (first hour after noon)', () => { + const result = parseTimeString('1:00 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 13, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 11:59 AM (last minute before noon)', () => { + const result = parseTimeString('11:59 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 11, + minute: 59, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 00:00:00 with seconds', () => { + const result = parseTimeString('00:00:00'); + expect(result).toEqual({ + kind: 'Time', + hour: 0, + minute: 0, + second: 0, + fraction: undefined, + }); + }); + + it('should parse 23:59:59 with seconds', () => { + const result = parseTimeString('23:59:59'); + expect(result).toEqual({ + kind: 'Time', + hour: 23, + minute: 59, + second: 59, + fraction: undefined, + }); + }); + }); + + describe('international formats', () => { + it('should parse European format (comma separator)', () => { + const result = parseTimeString('14:30:45,500'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: 45, + fraction: '500', + }); + }); + + it('should parse 24-hour format common in Asia/Europe', () => { + const result = parseTimeString('18:45'); + expect(result).toEqual({ + kind: 'Time', + hour: 18, + minute: 45, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse early morning time', () => { + const result = parseTimeString('06:30'); + expect(result).toEqual({ + kind: 'Time', + hour: 6, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + }); + + describe('formats mentioned in JSDoc', () => { + it('should parse locale time format: "9:07 AM"', () => { + const result = parseTimeString('9:07 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 7, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 24h format with leading zero: "09:00"', () => { + const result = parseTimeString('09:00'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 24h format: "14:30"', () => { + const result = parseTimeString('14:30'); + expect(result).toEqual({ + kind: 'Time', + hour: 14, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse short 24h format (single digit hour): "9:00"', () => { + const result = parseTimeString('9:00'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 0, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse 12h with lowercase period: "9:07 am"', () => { + const result = parseTimeString('9:07 am'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 7, + second: undefined, + fraction: undefined, + }); + }); + }); + + describe('real-world examples', () => { + it('should parse typical meeting time', () => { + const result = parseTimeString('9:30 AM'); + expect(result).toEqual({ + kind: 'Time', + hour: 9, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse lunch time', () => { + const result = parseTimeString('12:30 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 12, + minute: 30, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse evening time', () => { + const result = parseTimeString('7:45 PM'); + expect(result).toEqual({ + kind: 'Time', + hour: 19, + minute: 45, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse military time', () => { + const result = parseTimeString('13:45'); + expect(result).toEqual({ + kind: 'Time', + hour: 13, + minute: 45, + second: undefined, + fraction: undefined, + }); + }); + + it('should parse precise timestamp', () => { + const result = parseTimeString('10:23:47.891'); + expect(result).toEqual({ + kind: 'Time', + hour: 10, + minute: 23, + second: 47, + fraction: '891', + }); + }); + }); +}); diff --git a/src/parseTimeString.ts b/src/parseTimeString.ts new file mode 100644 index 0000000..b25bdb8 --- /dev/null +++ b/src/parseTimeString.ts @@ -0,0 +1,189 @@ +// parseTimeString.ts +// Standalone time parser for hh:mm format with AM/PM support +// Supports both 12-hour (with AM/PM) and 24-hour international formats + +import { ParseError } from './errors.js'; +import { toInt } from './helpers/toInt.js'; +import { lexTemporal } from './lexer.js'; +import type { AnyToken } from './lexer-types.js'; +import { TokType } from './lexer-types.js'; +import type { TimeAst } from './parser-types.js'; + +/** + * Parse a time-of-day string from multiple common formats. + * Supports: locale time ("9:07 AM"), 24h ("09:00", "14:30"), short 24h ("9:00"), + * and 12h with lowercase period ("9:07 am"). + * + * TAA (LLM) often generates time in 24h format ("09:00") instead of the locale + * format ("9:00 AM") that Luxon's 't' token expects. + * + * Supported formats: + * - Locale time (12-hour with AM/PM): "9:07 AM", "2:30 PM", "02:30PM", "2:30 p.m." + * - 24-hour format: "09:00", "14:30", "23:59" + * - Short 24-hour (single digit hour): "9:00", "9:30" + * - Lowercase am/pm: "9:07 am", "2:30 pm" + * - With optional seconds: "2:30:45 PM", "14:30:45" + * - With optional fractional seconds: "2:30:45.123 PM", "14:30:45.123" + * + * Returns a TimeAst object compatible with Temporal.PlainTime.from() + * + * @param input - The time string to parse + * @returns TimeAst object with hour, minute, and optional second/fraction + * @throws ParseError if the input is invalid + */ +export function parseTimeString(input: string): TimeAst { + const trimmed = input.trim(); + if (!trimmed) { + throw new ParseError('Empty time string. Expected format: "9:07 AM", "09:00", or "14:30"', 0); + } + + // Lex the input + const tokens = lexTemporal(trimmed); + + // Create a simple token parser + let i = 0; + const peek = (k = 0): AnyToken => tokens[Math.min(i + k, tokens.length - 1)]!; + const at = (type: TokType): boolean => peek().type === type; + const eat = (type: TokType): AnyToken => { + const t = peek(); + if (t.type !== type) { + throw new ParseError( + `Invalid time format: "${trimmed}". Expected format: "HH:MM" (e.g., "09:00", "14:30") or "H:MM AM/PM" (e.g., "9:07 AM")`, + i, + ); + } + i++; + return t; + }; + const tryEat = (type: TokType): AnyToken | null => { + if (at(type)) { + return eat(type); + } + return null; + }; + + // Parse: Number : Number [: Number [. Number]] [Ident] + // Example: 2:30 PM, 14:30, 2:30:45.123 PM + + // Parse hour + const hourTok = eat(TokType.Number); + let hour = toInt(hourTok.value, 'hour', i); + + // Parse colon + eat(TokType.Colon); + + // Parse minute + const minuteTok = eat(TokType.Number); + const minute = toInt(minuteTok.value, 'minute', i); + + // Validate minute format (must be 2 digits) + if (minuteTok.value.length !== 2) { + throw new ParseError( + `Invalid time format: "${trimmed}". Minutes must be 2 digits (e.g., "9:07" not "9:7")`, + i, + ); + } + + // Validate minute + if (minute < 0 || minute > 59) { + throw new ParseError(`Invalid minute: ${minute}. Minutes must be between 00-59`, i); + } + + // Optional seconds + let second: number | undefined; + let fraction: string | undefined; + + if (tryEat(TokType.Colon)) { + const secondTok = eat(TokType.Number); + second = toInt(secondTok.value, 'second', i); + + // Validate second + if (second < 0 || second > 59) { + throw new ParseError(`Invalid second: ${second}. Seconds must be between 00-59`, i); + } + + // Optional fractional seconds (. or ,) + if (tryEat(TokType.Dot) || tryEat(TokType.Comma)) { + const fracTok = eat(TokType.Number); + fraction = fracTok.value; + } + } + + // Optional AM/PM + let ampmStr: string | undefined; + if (at(TokType.Ident)) { + const identTok = eat(TokType.Ident); + const ident = String(identTok.value).toUpperCase(); + + // Check for AM/PM patterns: A, P, AM, PM + if (ident === 'A' || ident === 'P' || ident === 'AM' || ident === 'PM') { + ampmStr = ident[0]; // Extract 'A' or 'P' + } else { + throw new ParseError( + `Invalid time format: unexpected "${identTok.value}". Use AM/PM for 12-hour format or omit for 24-hour format`, + i, + ); + } + + // Optional dot after A/P (for a.m./p.m. format) + tryEat(TokType.Dot); + + // Optional M after dot (for a.m./p.m. format) + if (at(TokType.Ident)) { + const mTok = tryEat(TokType.Ident); + if (mTok && String(mTok.value).toUpperCase() !== 'M') { + throw new ParseError( + `Invalid time format: unexpected "${mTok.value}". Expected "a.m." or "p.m." format`, + i, + ); + } + // Optional dot after M + tryEat(TokType.Dot); + } + } + + // Expect EOF + eat(TokType.EOF); + + // Handle AM/PM conversion + if (ampmStr !== undefined) { + const isPM = ampmStr === 'P'; + + // Validate 12-hour format hour range + if (hour < 1 || hour > 12) { + throw new ParseError( + `Invalid hour for 12-hour format: ${hour}. Hours with AM/PM must be between 1-12 (e.g., "9:07 AM", not "0:07 AM" or "13:07 AM")`, + i, + ); + } + + // Convert to 24-hour format + if (isPM) { + // PM: 12 PM = 12, 1 PM = 13, ..., 11 PM = 23 + if (hour !== 12) { + hour += 12; + } + } else { + // AM: 12 AM = 0, 1 AM = 1, ..., 11 AM = 11 + if (hour === 12) { + hour = 0; + } + } + } else { + // 24-hour format validation + if (hour < 0 || hour > 23) { + throw new ParseError( + `Invalid hour for 24-hour format: ${hour}. Hours must be between 0-23 (e.g., "09:00", "14:30"). For hours > 12, use 24-hour format or add AM/PM`, + i, + ); + } + } + + return { + kind: 'Time', + hour, + minute, + second, + fraction, + }; +} diff --git a/src/parser.ts b/src/parser.ts index 5293cdd..87ac172 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -143,7 +143,7 @@ class Parser { // Check for optional leading dash (negative year / BC date) // ISO 8601: Year 0 = 1 BC, Year -1 = 2 BC, etc. const isNegative = this.tryEat(TokType.Dash); - + const yTok = this.eat(TokType.Number); let year = toInt(yTok.value, 'year', this.i); if (isNegative) {