From b9a3e18aa15b0d462c6c5ce5d9ca2b04aee66933 Mon Sep 17 00:00:00 2001 From: Stan Chang Date: Mon, 12 Jan 2026 18:43:41 +0800 Subject: [PATCH] feat: add comprehensive stringify API for AST serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full round-trip serialization support by converting parsed temporal ASTs back to ISO 8601/IXDTF formatted strings. Key features: - stringifyTemporal() for all temporal types (DateTime, Duration, Range) - Individual stringify functions for each AST component - Automatic normalization of offsets to canonical format (±HH:MM) - Component-based reconstruction for consistent output - Full round-trip compatibility with parser Includes 631 lines of tests covering: - Unit tests for each stringify function - Round-trip parsing validation - Format normalization (compact/short → canonical) - Edge cases (year 0001, midnight, empty duration, negative zero) - RFC 9557 compliance (calendar annotations, critical flags) This enables workflows like: - AST manipulation and re-serialization - Format normalization and validation - Temporal data transformation pipelines --- .changeset/kind-donkeys-bake.md | 28 ++ README.md | 44 +++ src/index.ts | 13 + src/parser-types.ts | 14 +- src/stringify.test.ts | 631 ++++++++++++++++++++++++++++++++ src/stringify.ts | 219 +++++++++++ 6 files changed, 947 insertions(+), 2 deletions(-) create mode 100644 .changeset/kind-donkeys-bake.md create mode 100644 src/stringify.test.ts create mode 100644 src/stringify.ts diff --git a/.changeset/kind-donkeys-bake.md b/.changeset/kind-donkeys-bake.md new file mode 100644 index 0000000..5e1cb4e --- /dev/null +++ b/.changeset/kind-donkeys-bake.md @@ -0,0 +1,28 @@ +--- +'@taskade/temporal-parser': minor +--- + +feat: add comprehensive stringify API for AST serialization + +Add comprehensive stringify API for AST serialization. Implements full round-trip serialization support by converting parsed temporal ASTs back to ISO 8601/IXDTF formatted strings. + +**New exports:** +- `stringifyTemporal()` - Main function for all temporal types +- `stringifyDate()`, `stringifyTime()`, `stringifyDateTime()` - DateTime components +- `stringifyDuration()`, `stringifyRange()` - Duration and Range types +- `stringifyOffset()`, `stringifyTimeZone()`, `stringifyAnnotation()` - Supporting components + +**Features:** +- Automatic normalization of offsets to canonical format (±HH:MM) +- Component-based reconstruction for consistent output +- Full round-trip compatibility with parser +- Preserves all AST information including annotations and critical flags + +**Example:** +```typescript +import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser'; + +const ast = parseTemporal('2025-01-12T10:00:00+0530'); // Compact format +const normalized = stringifyTemporal(ast); +// '2025-01-12T10:00:00+05:30' (canonical format) +``` diff --git a/README.md b/README.md index 4e98ef8..e055b00 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,34 @@ const offset = parseOffset('+08:00'); // { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' } ``` +### Stringify AST Back to String + +```typescript +import { parseTemporal, stringifyTemporal } from '@taskade/temporal-parser'; + +// Parse and stringify +const ast = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore]'); +const str = stringifyTemporal(ast); +// '2025-01-12T10:00:00+08:00[Asia/Singapore]' + +// Offsets are normalized to canonical format (±HH:MM) +const ast2 = parseTemporal('2025-01-12T10:00:00+0530'); // Compact format +const str2 = stringifyTemporal(ast2); +// '2025-01-12T10:00:00+05:30' (normalized) + +// Stringify individual components +import { stringifyDate, stringifyTime, stringifyDuration } from '@taskade/temporal-parser'; + +stringifyDate({ kind: 'Date', year: 2025, month: 1, day: 12 }); +// '2025-01-12' + +stringifyTime({ kind: 'Time', hour: 10, minute: 30, second: 45 }); +// '10:30:45' + +stringifyDuration({ kind: 'Duration', years: 1, months: 2, raw: 'P1Y2M', annotations: [] }); +// 'P1Y2M' +``` + ## Motivation Time is one of the most complex human inventions. @@ -169,6 +197,22 @@ Parses a numeric timezone offset string. - Hours: 0-14 (UTC-12:00 to UTC+14:00) - Minutes: 0-59 +### `stringifyTemporal(ast: TemporalAst): string` + +Converts a temporal AST back to its string representation. + +**Returns:** ISO 8601 / IXDTF formatted string + +**Also available:** +- `stringifyDate(date: DateAst): string` +- `stringifyTime(time: TimeAst): string` +- `stringifyDateTime(dateTime: DateTimeAst): string` +- `stringifyDuration(duration: DurationAst): string` +- `stringifyRange(range: RangeAst): string` +- `stringifyOffset(offset: OffsetAst): string` +- `stringifyTimeZone(timeZone: TimeZoneAst): string` +- `stringifyAnnotation(annotation: AnnotationAst): string` + ## TypeScript Support Full TypeScript definitions are included. All AST types are exported: diff --git a/src/index.ts b/src/index.ts index 0538fdf..03b7cc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,19 @@ export { parseTemporal } from './parser.js'; // Export offset parser (useful standalone utility) export { parseOffset } from './parseOffset.js'; +// Export stringify functionality +export { + stringifyAnnotation, + stringifyDate, + stringifyDateTime, + stringifyDuration, + stringifyOffset, + stringifyRange, + stringifyTemporal, + stringifyTime, + stringifyTimeZone, +} from './stringify.js'; + // Export parser types export type { AnnotationAst, diff --git a/src/parser-types.ts b/src/parser-types.ts index 80dde67..e386cae 100644 --- a/src/parser-types.ts +++ b/src/parser-types.ts @@ -22,6 +22,10 @@ export type DateTimeAst = { export type DateAst = { kind: 'Date'; + /** + * Components are receivable by Temporal.PlainDate.from(). + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainDate/from + */ year: number; month?: number; day?: number; @@ -29,6 +33,10 @@ export type DateAst = { export type TimeAst = { kind: 'Time'; + /** + * Components are receivable by Temporal.PlainTime.from(). + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainTime/from + */ hour: number; minute: number; second?: number; @@ -61,8 +69,10 @@ export type AnnotationAst = { export type DurationAst = { kind: 'Duration'; - // Keep both parsed fields and the original string form. - // Months vs minutes ambiguity is handled by position (date part vs time part). + // ISO 8601 duration components (P1Y2M3DT4H5M6S) + // Note: 'M' is disambiguated by position - months in date part, minutes in time part + // Components are compatible with Temporal.Duration.from() + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration/from years?: number; months?: number; weeks?: number; diff --git a/src/stringify.test.ts b/src/stringify.test.ts new file mode 100644 index 0000000..b92fd90 --- /dev/null +++ b/src/stringify.test.ts @@ -0,0 +1,631 @@ +// stringify.test.ts +import { describe, expect, it } from 'vitest'; + +import { parseTemporal } from './parser.js'; +import { + stringifyAnnotation, + stringifyDate, + stringifyDateTime, + stringifyDuration, + stringifyOffset, + stringifyRange, + stringifyTemporal, + stringifyTime, + stringifyTimeZone, +} from './stringify.js'; + +describe('stringifyDate', () => { + it('should stringify year only', () => { + const result = stringifyDate({ kind: 'Date', year: 2025 }); + expect(result).toBe('2025'); + }); + + it('should stringify year-month', () => { + const result = stringifyDate({ kind: 'Date', year: 2025, month: 1 }); + expect(result).toBe('2025-01'); + }); + + it('should stringify full date', () => { + const result = stringifyDate({ kind: 'Date', year: 2025, month: 1, day: 12 }); + expect(result).toBe('2025-01-12'); + }); + + it('should pad single digits with zeros', () => { + const result = stringifyDate({ kind: 'Date', year: 2025, month: 3, day: 7 }); + expect(result).toBe('2025-03-07'); + }); + + it('should pad year to 4 digits', () => { + const result = stringifyDate({ kind: 'Date', year: 999, month: 12, day: 31 }); + expect(result).toBe('0999-12-31'); + }); +}); + +describe('stringifyTime', () => { + it('should stringify hour and minute', () => { + const result = stringifyTime({ kind: 'Time', hour: 10, minute: 30 }); + expect(result).toBe('10:30'); + }); + + it('should stringify with seconds', () => { + const result = stringifyTime({ kind: 'Time', hour: 10, minute: 30, second: 45 }); + expect(result).toBe('10:30:45'); + }); + + it('should stringify with fractional seconds', () => { + const result = stringifyTime({ + kind: 'Time', + hour: 10, + minute: 30, + second: 45, + fraction: '123', + }); + expect(result).toBe('10:30:45.123'); + }); + + it('should pad single digits with zeros', () => { + const result = stringifyTime({ kind: 'Time', hour: 9, minute: 5, second: 3 }); + expect(result).toBe('09:05:03'); + }); +}); + +describe('stringifyOffset', () => { + it('should stringify UTC offset', () => { + const result = stringifyOffset({ kind: 'UtcOffset' }); + expect(result).toBe('Z'); + }); + + it('should stringify numeric offset in canonical format', () => { + const result = stringifyOffset({ + kind: 'NumericOffset', + sign: '+', + hours: 8, + minutes: 0, + raw: '+08:00', + }); + expect(result).toBe('+08:00'); + }); + + it('should normalize compact format to canonical', () => { + const result = stringifyOffset({ + kind: 'NumericOffset', + sign: '+', + hours: 5, + minutes: 30, + raw: '+0530', + }); + expect(result).toBe('+05:30'); // Normalized to canonical + }); + + it('should normalize short format to canonical', () => { + const result = stringifyOffset({ + kind: 'NumericOffset', + sign: '+', + hours: 9, + minutes: 0, + raw: '+09', + }); + expect(result).toBe('+09:00'); // Normalized to canonical + }); + + it('should pad single digit hours and minutes', () => { + const result = stringifyOffset({ + kind: 'NumericOffset', + sign: '-', + hours: 5, + minutes: 30, + raw: '-05:30', + }); + expect(result).toBe('-05:30'); + }); +}); + +describe('stringifyTimeZone', () => { + it('should stringify IANA timezone', () => { + const result = stringifyTimeZone({ + kind: 'IanaTimeZone', + id: 'Asia/Singapore', + critical: false, + }); + expect(result).toBe('[Asia/Singapore]'); + }); + + it('should stringify critical timezone', () => { + const result = stringifyTimeZone({ + kind: 'IanaTimeZone', + id: 'America/New_York', + critical: true, + }); + expect(result).toBe('[!America/New_York]'); + }); +}); + +describe('stringifyAnnotation', () => { + it('should stringify annotation', () => { + const result = stringifyAnnotation({ + kind: 'Annotation', + raw: 'u-ca=gregory', + critical: false, + pairs: { 'u-ca': 'gregory' }, + }); + expect(result).toBe('[u-ca=gregory]'); + }); + + it('should stringify critical annotation', () => { + const result = stringifyAnnotation({ + kind: 'Annotation', + raw: '!u-ca=iso8601', + critical: true, + pairs: { 'u-ca': 'iso8601' }, + }); + expect(result).toBe('[!u-ca=iso8601]'); + }); +}); + +describe('stringifyDateTime', () => { + it('should stringify date only', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + annotations: [], + }); + expect(result).toBe('2025-01-12'); + }); + + it('should stringify date with time', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 30, second: 45 }, + annotations: [], + }); + expect(result).toBe('2025-01-12T10:30:45'); + }); + + it('should stringify with UTC offset', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 0, second: 0 }, + offset: { kind: 'UtcOffset' }, + annotations: [], + }); + expect(result).toBe('2025-01-12T10:00:00Z'); + }); + + it('should stringify with numeric offset', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 0, second: 0 }, + offset: { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' }, + annotations: [], + }); + expect(result).toBe('2025-01-12T10:00:00+08:00'); + }); + + it('should stringify with timezone', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 0, second: 0 }, + offset: { kind: 'NumericOffset', sign: '+', hours: 8, minutes: 0, raw: '+08:00' }, + timeZone: { kind: 'IanaTimeZone', id: 'Asia/Singapore', critical: false }, + annotations: [], + }); + expect(result).toBe('2025-01-12T10:00:00+08:00[Asia/Singapore]'); + }); + + it('should stringify with annotations', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 0, second: 0 }, + offset: { kind: 'UtcOffset' }, + annotations: [ + { kind: 'Annotation', raw: 'u-ca=gregory', critical: false, pairs: {} }, + { kind: 'Annotation', raw: 'u-tz=UTC', critical: false, pairs: {} }, + ], + }); + expect(result).toBe('2025-01-12T10:00:00Z[u-ca=gregory][u-tz=UTC]'); + }); + + it('should stringify with fractional seconds', () => { + const result = stringifyDateTime({ + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 12 }, + time: { kind: 'Time', hour: 10, minute: 30, second: 45, fraction: '123456' }, + annotations: [], + }); + expect(result).toBe('2025-01-12T10:30:45.123456'); + }); +}); + +describe('stringifyDuration', () => { + it('should stringify duration with date parts', () => { + const result = stringifyDuration({ + kind: 'Duration', + years: 1, + months: 2, + days: 3, + raw: 'P1Y2M3D', + annotations: [], + }); + expect(result).toBe('P1Y2M3D'); + }); + + it('should stringify duration with time parts', () => { + const result = stringifyDuration({ + kind: 'Duration', + hours: 4, + minutes: 5, + seconds: 6, + raw: 'PT4H5M6S', + annotations: [], + }); + expect(result).toBe('PT4H5M6S'); + }); + + it('should stringify combined duration', () => { + const result = stringifyDuration({ + kind: 'Duration', + years: 1, + months: 2, + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + raw: 'P1Y2M3DT4H5M6S', + annotations: [], + }); + expect(result).toBe('P1Y2M3DT4H5M6S'); + }); + + it('should stringify duration with fractional seconds', () => { + const result = stringifyDuration({ + kind: 'Duration', + seconds: 1, + secondsFraction: '5', + raw: 'PT1.5S', + annotations: [], + }); + expect(result).toBe('PT1.5S'); + }); + + it('should stringify duration with weeks', () => { + const result = stringifyDuration({ + kind: 'Duration', + weeks: 3, + raw: 'P3W', + annotations: [], + }); + expect(result).toBe('P3W'); + }); + + it('should stringify duration with annotations', () => { + const result = stringifyDuration({ + kind: 'Duration', + days: 1, + raw: 'P1D', + annotations: [{ kind: 'Annotation', raw: 'u-ca=gregory', critical: false, pairs: {} }], + }); + expect(result).toBe('P1D[u-ca=gregory]'); + }); + + it('should reconstruct duration without raw', () => { + const result = stringifyDuration({ + kind: 'Duration', + years: 1, + months: 2, + annotations: [], + raw: 'P1Y2M', + }); + expect(result).toBe('P1Y2M'); + }); + + it('should handle duration with only time parts', () => { + const result = stringifyDuration({ + kind: 'Duration', + hours: 2, + minutes: 30, + annotations: [], + raw: 'PT2H30M', + }); + expect(result).toBe('PT2H30M'); + }); + + it('should include zero values if defined', () => { + const result = stringifyDuration({ + kind: 'Duration', + years: 0, + months: 0, + days: 1, + annotations: [], + raw: 'P0Y0M1D', + }); + expect(result).toBe('P0Y0M1D'); + }); + + it('should include PT0H (zero hours)', () => { + const result = stringifyDuration({ + kind: 'Duration', + hours: 0, + annotations: [], + raw: 'PT0H', + }); + expect(result).toBe('PT0H'); + }); + + it('should include PT0S (zero seconds)', () => { + const result = stringifyDuration({ + kind: 'Duration', + seconds: 0, + annotations: [], + raw: 'PT0S', + }); + expect(result).toBe('PT0S'); + }); +}); + +describe('stringifyRange', () => { + it('should stringify closed range', () => { + const result = stringifyRange({ + kind: 'Range', + start: { + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 1 }, + annotations: [], + }, + end: { + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 12, day: 31 }, + annotations: [], + }, + }); + expect(result).toBe('2025-01-01/2025-12-31'); + }); + + it('should stringify open start range', () => { + const result = stringifyRange({ + kind: 'Range', + start: null, + end: { + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 12, day: 31 }, + annotations: [], + }, + }); + expect(result).toBe('/2025-12-31'); + }); + + it('should stringify open end range', () => { + const result = stringifyRange({ + kind: 'Range', + start: { + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 1 }, + annotations: [], + }, + end: null, + }); + expect(result).toBe('2025-01-01/'); + }); + + it('should stringify range with duration', () => { + const result = stringifyRange({ + kind: 'Range', + start: { + kind: 'DateTime', + date: { kind: 'Date', year: 2025, month: 1, day: 1 }, + annotations: [], + }, + end: { + kind: 'Duration', + years: 1, + raw: 'P1Y', + annotations: [], + }, + }); + expect(result).toBe('2025-01-01/P1Y'); + }); + + it('should stringify duration to duration range', () => { + const result = stringifyRange({ + kind: 'Range', + start: { + kind: 'Duration', + days: 1, + raw: 'P1D', + annotations: [], + }, + end: { + kind: 'Duration', + days: 7, + raw: 'P7D', + annotations: [], + }, + }); + expect(result).toBe('P1D/P7D'); + }); +}); + +describe('stringifyTemporal', () => { + it('should stringify datetime', () => { + const ast = parseTemporal('2025-01-12T10:00:00+08:00'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00+08:00'); + }); + + it('should stringify duration', () => { + const ast = parseTemporal('P1Y2M3DT4H5M6S'); + const result = stringifyTemporal(ast); + expect(result).toBe('P1Y2M3DT4H5M6S'); + }); + + it('should stringify range', () => { + const ast = parseTemporal('2025-01-01/2025-12-31'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-01/2025-12-31'); + }); +}); + +describe('round-trip parsing', () => { + const testCases = [ + '2025', + '2025-01', + '2025-01-12', + '2025-01-12T10:30', + '2025-01-12T10:30:45', + '2025-01-12T10:30:45.123', + '2025-01-12T10:00:00Z', + '2025-01-12T10:00:00+08:00', + '2025-01-12T10:00:00-05:30', + '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]', + 'P1Y', + 'P1Y2M', + 'P1Y2M3D', + 'PT1H', + 'PT1H30M', + 'PT1H30M45S', + 'P1Y2M3DT4H5M6S', + 'PT1.5S', + 'P3W', + '2025-01-01/2025-12-31', + '/2025-12-31', + '2025-01-01/', + '2025-01-01/P1Y', + 'P1D/P7D', + ]; + + testCases.forEach((input) => { + it(`should round-trip: ${input}`, () => { + const ast = parseTemporal(input); + const output = stringifyTemporal(ast); + expect(output).toBe(input); + }); + }); +}); + +describe('format normalization', () => { + it('should normalize compact offset to canonical', () => { + const ast = parseTemporal('2025-01-12T10:00:00+0530'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00+05:30'); + }); + + it('should normalize short offset to canonical', () => { + const ast = parseTemporal('2025-01-12T10:00:00+09'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00+09:00'); + }); + + it('should normalize negative compact offset', () => { + const ast = parseTemporal('2025-01-12T10:00:00-0800'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00-08:00'); + }); +}); + +describe('edge cases', () => { + it('should handle year 0001', () => { + const result = stringifyDate({ kind: 'Date', year: 1, month: 1, day: 1 }); + expect(result).toBe('0001-01-01'); + }); + + it('should handle midnight', () => { + const result = stringifyTime({ kind: 'Time', hour: 0, minute: 0, second: 0 }); + expect(result).toBe('00:00:00'); + }); + + it('should handle empty duration (P only)', () => { + const result = stringifyDuration({ + kind: 'Duration', + raw: 'P', + annotations: [], + }); + expect(result).toBe('P'); + }); + + it('should handle open-open range', () => { + const result = stringifyRange({ + kind: 'Range', + start: null, + end: null, + }); + expect(result).toBe('/'); + }); + + it('should handle negative zero offset', () => { + const ast = parseTemporal('2025-01-12T10:00:00-00:00'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00-00:00'); + }); + + it('should handle very long fractional seconds', () => { + const ast = parseTemporal('2025-01-12T10:30:45.123456789'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:30:45.123456789'); + }); +}); + +describe('RFC 9557 format', () => { + it('should stringify date with calendar annotation', () => { + const ast = parseTemporal('2025-01-12[u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12[u-ca=gregory]'); + }); + + it('should stringify date with different calendar systems', () => { + const testCases = [ + '2025-01-12[u-ca=iso8601]', + '2025-01-12[u-ca=hebrew]', + '2025-01-12[u-ca=islamic]', + '2025-01-12[u-ca=japanese]', + '2025-01-12[u-ca=chinese]', + ]; + + testCases.forEach((input) => { + const ast = parseTemporal(input); + const result = stringifyTemporal(ast); + expect(result).toBe(input); + }); + }); + + it('should stringify date with critical calendar annotation', () => { + const ast = parseTemporal('2025-01-12[!u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12[!u-ca=gregory]'); + }); + + it('should stringify datetime with calendar annotation', () => { + const ast = parseTemporal('2025-01-12T10:30:45[u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:30:45[u-ca=gregory]'); + }); + + it('should stringify datetime with timezone and calendar annotation', () => { + const ast = parseTemporal('2025-01-12T10:00:00+08:00[Asia/Singapore][u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00+08:00[Asia/Singapore][u-ca=gregory]'); + }); + + it('should stringify with multiple annotations', () => { + const ast = parseTemporal('2025-01-12T10:00:00Z[u-ca=gregory][u-tz=UTC]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01-12T10:00:00Z[u-ca=gregory][u-tz=UTC]'); + }); + + it('should stringify year-month with calendar annotation', () => { + const ast = parseTemporal('2025-01[u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025-01[u-ca=gregory]'); + }); + + it('should stringify year only with calendar annotation', () => { + const ast = parseTemporal('2025[u-ca=gregory]'); + const result = stringifyTemporal(ast); + expect(result).toBe('2025[u-ca=gregory]'); + }); +}); diff --git a/src/stringify.ts b/src/stringify.ts new file mode 100644 index 0000000..851e639 --- /dev/null +++ b/src/stringify.ts @@ -0,0 +1,219 @@ +// stringify.ts +// Convert AST back to temporal string representation + +import type { + AnnotationAst, + DateAst, + DateTimeAst, + DurationAst, + OffsetAst, + RangeAst, + TemporalAst, + TimeAst, + TimeZoneAst, +} from './parser-types.js'; + +/** + * Convert a temporal AST back to its string representation. + * + * @param ast - The AST to stringify + * @returns ISO 8601 / IXDTF formatted string + * + * @example + * ```typescript + * const ast = parseTemporal('2025-01-12T10:00:00+08:00'); + * const str = stringifyTemporal(ast); + * // '2025-01-12T10:00:00+08:00' + * ``` + */ +export function stringifyTemporal(ast: TemporalAst): string { + if (ast.kind === 'Range') { + return stringifyRange(ast); + } + if (ast.kind === 'Duration') { + return stringifyDuration(ast); + } + return stringifyDateTime(ast); +} + +/** + * Stringify a date AST to ISO 8601 format. + * + * @param date - The date AST + * @returns Date string (YYYY, YYYY-MM, or YYYY-MM-DD) + */ +export function stringifyDate(date: DateAst): string { + const parts: string[] = [date.year.toString().padStart(4, '0')]; + + if (date.month != null) { + parts.push(date.month.toString().padStart(2, '0')); + + if (date.day != null) { + parts.push(date.day.toString().padStart(2, '0')); + } + } + + return parts.join('-'); +} + +/** + * Stringify a time AST to ISO 8601 format. + * + * @param time - The time AST + * @returns Time string (HH:MM, HH:MM:SS, or HH:MM:SS.fff) + */ +export function stringifyTime(time: TimeAst): string { + const parts: string[] = [ + time.hour.toString().padStart(2, '0'), + time.minute.toString().padStart(2, '0'), + ]; + + if (time.second != null) { + let secondStr = time.second.toString().padStart(2, '0'); + if (time.fraction != null) { + secondStr += `.${time.fraction}`; + } + parts.push(secondStr); + } + + return parts.join(':'); +} + +/** + * Stringify a timezone offset AST to canonical format. + * + * @param offset - The offset AST + * @returns Offset string in canonical format (Z or ±HH:MM) + */ +export function stringifyOffset(offset: OffsetAst): string { + if (offset.kind === 'UtcOffset') { + return 'Z'; + } + // Return canonical format: +HH:MM or -HH:MM + const hours = offset.hours.toString().padStart(2, '0'); + const minutes = offset.minutes.toString().padStart(2, '0'); + return `${offset.sign}${hours}:${minutes}`; +} + +/** + * Stringify a timezone AST. + * + * @param timeZone - The timezone AST + * @returns Timezone string [Asia/Singapore] or [!Asia/Singapore] + */ +export function stringifyTimeZone(timeZone: TimeZoneAst): string { + const id = timeZone.critical ? `!${timeZone.id}` : timeZone.id; + return `[${id}]`; +} + +/** + * Stringify an annotation AST. + * + * @param annotation - The annotation AST + * @returns Annotation string [u-ca=gregory] or [!u-ca=gregory] + */ +export function stringifyAnnotation(annotation: AnnotationAst): string { + return `[${annotation.raw}]`; +} + +/** + * Stringify a datetime AST to ISO 8601 / IXDTF format. + * + * @param dateTime - The datetime AST + * @returns DateTime string with optional time, offset, timezone, and annotations + */ +export function stringifyDateTime(dateTime: DateTimeAst): string { + let result = stringifyDate(dateTime.date); + + if (dateTime.time) { + result += `T${stringifyTime(dateTime.time)}`; + } + + if (dateTime.offset) { + result += stringifyOffset(dateTime.offset); + } + + if (dateTime.timeZone) { + result += stringifyTimeZone(dateTime.timeZone); + } + + for (const annotation of dateTime.annotations) { + result += stringifyAnnotation(annotation); + } + + return result; +} + +/** + * Stringify a duration AST to ISO 8601 format. + * + * @param duration - The duration AST + * @returns Duration string (P1Y2M3DT4H5M6S) + */ +export function stringifyDuration(duration: DurationAst): string { + // Always reconstruct from components to ensure normalization + let result = 'P'; + + // Date part - include component if defined (even if zero) + if (duration.years != null) { + result += `${duration.years}Y`; + } + if (duration.months != null) { + result += `${duration.months}M`; + } + if (duration.weeks != null) { + result += `${duration.weeks}W`; + } + if (duration.days != null) { + result += `${duration.days}D`; + } + + // Time part - add T separator if any time component is defined + const hasTimePart = + duration.hours != null || duration.minutes != null || duration.seconds != null; + if (hasTimePart) { + result += 'T'; + if (duration.hours != null) { + result += `${duration.hours}H`; + } + if (duration.minutes != null) { + result += `${duration.minutes}M`; + } + if (duration.seconds != null) { + result += `${duration.seconds}`; + if (duration.secondsFraction != null && duration.secondsFraction.length > 0) { + result += `.${duration.secondsFraction}`; + } + result += 'S'; + } + } + + // Annotations + for (const annotation of duration.annotations) { + result += stringifyAnnotation(annotation); + } + + return result; +} + +/** + * Stringify a range AST to ISO 8601 format. + * + * @param range - The range AST + * @returns Range string (start/end, /end, or start/) + */ +export function stringifyRange(range: RangeAst): string { + const start = range.start + ? range.start.kind === 'Duration' + ? stringifyDuration(range.start) + : stringifyDateTime(range.start) + : ''; + + const end = range.end + ? range.end.kind === 'Duration' + ? stringifyDuration(range.end) + : stringifyDateTime(range.end) + : ''; + + return `${start}/${end}`; +}