Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/parser-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type RangeAst = {
end: ValueAst | null; // null => open end
};

export type ValueAst = DateTimeAst | DurationAst;
export type ValueAst = DateTimeAst | DurationAst | TimeAst;

export type DateTimeAst = {
kind: 'DateTime';
Expand Down
118 changes: 118 additions & 0 deletions src/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,79 @@ describe('parseTemporal', () => {
});
});

describe('standalone time parsing (ISO 8601 Clause 5.4)', () => {
it('should parse time with hour and minute (HH:MM)', () => {
const ast = parseTemporal('10:30');
expect(ast).toMatchObject({
kind: 'Time',
hour: 10,
minute: 30,
});
});

it('should parse time with seconds (HH:MM:SS)', () => {
const ast = parseTemporal('10:30:45');
expect(ast).toMatchObject({
kind: 'Time',
hour: 10,
minute: 30,
second: 45,
});
});

it('should parse time with fractional seconds (HH:MM:SS.fff)', () => {
const ast = parseTemporal('10:30:45.123');
expect(ast).toMatchObject({
kind: 'Time',
hour: 10,
minute: 30,
second: 45,
fraction: '123',
});
});

it('should parse time with high-precision fractional seconds', () => {
const ast = parseTemporal('23:59:59.999999999');
expect(ast).toMatchObject({
kind: 'Time',
hour: 23,
minute: 59,
second: 59,
fraction: '999999999',
});
});

it('should parse time with comma decimal separator (European format)', () => {
const ast = parseTemporal('10:30:45,123');
expect(ast).toMatchObject({
kind: 'Time',
hour: 10,
minute: 30,
second: 45,
fraction: '123',
});
});

it('should parse midnight', () => {
const ast = parseTemporal('00:00:00');
expect(ast).toMatchObject({
kind: 'Time',
hour: 0,
minute: 0,
second: 0,
});
});

it('should parse noon with reduced precision', () => {
const ast = parseTemporal('12:00');
expect(ast).toMatchObject({
kind: 'Time',
hour: 12,
minute: 0,
});
});
});

describe('timezone parsing', () => {
it('should parse Z timezone', () => {
const ast = parseTemporal('2025-01-07T10:00:00Z');
Expand Down Expand Up @@ -600,6 +673,51 @@ describe('parseTemporal', () => {
},
});
});

it('should parse time range', () => {
const ast = parseTemporal('10:00/12:00');
expect(ast).toMatchObject({
kind: 'Range',
start: {
kind: 'Time',
hour: 10,
minute: 0,
},
end: {
kind: 'Time',
hour: 12,
minute: 0,
},
});
});

it('should parse open-start time range', () => {
const ast = parseTemporal('/23:59:59');
expect(ast).toMatchObject({
kind: 'Range',
start: null,
end: {
kind: 'Time',
hour: 23,
minute: 59,
second: 59,
},
});
});

it('should parse open-end time range', () => {
const ast = parseTemporal('00:00:00/');
expect(ast).toMatchObject({
kind: 'Range',
start: {
kind: 'Time',
hour: 0,
minute: 0,
second: 0,
},
end: null,
});
});
});

describe('error handling', () => {
Expand Down
8 changes: 7 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ class Parser {
return this.parseDuration();
}

// Standalone time format: Number:Number (e.g., 10:30, 10:30:45)
// ISO 8601 Clause 5.4: time-only representations
if (this.at(TokType.Number) && this.peek(1).type === TokType.Colon) {
return this.parseTime();
}

// Otherwise parse a date/datetime (common case)
return this.parseDateTime();
}
Expand Down Expand Up @@ -143,7 +149,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) {
Expand Down
26 changes: 26 additions & 0 deletions src/stringify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,24 @@ describe('stringifyTemporal', () => {
const result = stringifyTemporal(ast);
expect(result).toBe('2025-01-01/2025-12-31');
});

it('should stringify standalone time', () => {
const ast = parseTemporal('10:30:45');
const result = stringifyTemporal(ast);
expect(result).toBe('10:30:45');
});

it('should stringify standalone time with reduced precision', () => {
const ast = parseTemporal('10:30');
const result = stringifyTemporal(ast);
expect(result).toBe('10:30');
});

it('should stringify standalone time with fractional seconds', () => {
const ast = parseTemporal('10:30:45.123');
const result = stringifyTemporal(ast);
expect(result).toBe('10:30:45.123');
});
});

describe('round-trip parsing', () => {
Expand All @@ -483,6 +501,11 @@ describe('round-trip parsing', () => {
'2025-01-12T10:00:00+08:00[Asia/Singapore]',
'2025-01-12T10:00:00Z[u-ca=gregory]',
'2025-01-12T10:00:00+08:00[Asia/Singapore][u-ca=gregory]',
'10:30',
'10:30:45',
'10:30:45.123',
'00:00:00',
'23:59:59.999999999',
'P1Y',
'P1Y2M',
'P1Y2M3D',
Expand All @@ -497,6 +520,9 @@ describe('round-trip parsing', () => {
'2025-01-01/',
'2025-01-01/P1Y',
'P1D/P7D',
'10:00/12:00',
'/23:59:59',
'00:00:00/',
];

testCases.forEach((input) => {
Expand Down
11 changes: 9 additions & 2 deletions src/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export function stringifyTemporal(ast: TemporalAst): string {
if (ast.kind === 'Duration') {
return stringifyDuration(ast);
}
if (ast.kind === 'Time') {
return stringifyTime(ast);
}
return stringifyDateTime(ast);
}

Expand Down Expand Up @@ -216,13 +219,17 @@ export function stringifyRange(range: RangeAst): string {
const start = range.start
? range.start.kind === 'Duration'
? stringifyDuration(range.start)
: stringifyDateTime(range.start)
: range.start.kind === 'Time'
? stringifyTime(range.start)
: stringifyDateTime(range.start)
: '';

const end = range.end
? range.end.kind === 'Duration'
? stringifyDuration(range.end)
: stringifyDateTime(range.end)
: range.end.kind === 'Time'
? stringifyTime(range.end)
: stringifyDateTime(range.end)
: '';

return `${start}/${end}`;
Expand Down