diff --git a/.bumpy/timezone-data-type.md b/.bumpy/timezone-data-type.md new file mode 100644 index 000000000..15af90fa3 --- /dev/null +++ b/.bumpy/timezone-data-type.md @@ -0,0 +1,6 @@ +--- +varlock: minor +env-spec-language: patch +--- + +Add built-in `timeZone` data type that validates IANA time zone names. Rename the `simple-object` data type to `simpleObject` so all built-in type names are camelCase (`simple-object` still works but logs a deprecation warning). diff --git a/packages/varlock-website/src/content/docs/reference/data-types.mdx b/packages/varlock-website/src/content/docs/reference/data-types.mdx index c6b6ceb3f..c08b62982 100644 --- a/packages/varlock-website/src/content/docs/reference/data-types.mdx +++ b/packages/varlock-website/src/content/docs/reference/data-types.mdx @@ -300,6 +300,15 @@ MY_DATE=2024-03-20T15:30:00Z ``` +
+### `timeZone` +Checks for a valid [IANA time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name, using the time zone database built into the JS runtime. Names are case-sensitive, and UTC offsets like `+05:00` are rejected. +```env-spec +# @type=timeZone +APP_TIME_ZONE=Europe/Madrid +``` +
+
### `uuid` Checks for valid [UUID](https://en.wikipedia.org/wiki/UUID) (versions 1-8 per [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562), including `NIL` and `MAX`). @@ -327,10 +336,10 @@ MY_HASH=d41d8cd98f00b204e9800998ecf8427e
-### `simple-object` -Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. +### `simpleObject` +Validates and coerces JSON strings into objects. Equivalent to a bare [`record`](#record); prefer `record`, which can also validate keys and values. Formerly named `simple-object`, which still works but is deprecated. ```env-spec -# @type=simple-object +# @type=simpleObject MY_OBJECT={"key": "value"} ```
@@ -430,6 +439,6 @@ Validation errors are reported per entry (`"apac" is not a valid key - ...`). In application code, `ENV.ENDPOINTS` is typed as `Record` (enum-constrained keys narrow further). In `process.env` the value is JSON. -An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simple-object` type behaves the same as a bare `record`. +An untyped item whose value is an object literal (e.g. `ITEM={k=v}`) is inferred as a record automatically. The existing `simpleObject` type behaves the same as a bare `record`. diff --git a/packages/varlock/BUNDLED_ICONS_LICENSES.md b/packages/varlock/BUNDLED_ICONS_LICENSES.md index a9f196c82..a80b17ff7 100644 --- a/packages/varlock/BUNDLED_ICONS_LICENSES.md +++ b/packages/varlock/BUNDLED_ICONS_LICENSES.md @@ -698,7 +698,7 @@ Apache License - Author: Pictogrammers (https://github.com/Templarian/MaterialDesign) - License: Apache 2.0 (Apache-2.0), https://github.com/Templarian/MaterialDesign/blob/master/LICENSE -- Icons used: `mdi:identifier`, `mdi:timer-outline`, `mdi:web` +- Icons used: `mdi:identifier`, `mdi:map-clock-outline`, `mdi:timer-outline`, `mdi:web` ``` Pictogrammers Free License diff --git a/packages/varlock/src/env-graph/lib/data-types.ts b/packages/varlock/src/env-graph/lib/data-types.ts index 3f1aa4fc6..0ef84a7db 100644 --- a/packages/varlock/src/env-graph/lib/data-types.ts +++ b/packages/varlock/src/env-graph/lib/data-types.ts @@ -795,7 +795,7 @@ const DomainDataType = createEnvGraphDataType( const SimpleObjectDataType = createEnvGraphDataType({ - name: 'simple-object', + name: 'simpleObject', icon: 'tabler:code-dots', // curly brackets with nothing inside coercedType: 'object', coerce(val) { @@ -973,6 +973,52 @@ const IsoDateDataType = createEnvGraphDataType({ }); +// Validation leans on the runtime's own tz database via `Intl`, so we ship no zone list. +// Engines accept a few things that are not IANA names (UTC offsets like `+05:00`) and match +// names case-insensitively, while most consumers (e.g. the `TZ` env var on Linux) are +// case-sensitive, so we check both explicitly. +// NOTE: mirrored in packages/vscode-plugin/src/diagnostics-core.ts - keep them in sync. +let lowercaseTimeZoneLookup: Map | undefined; +function findTimeZoneByCase(val: string): string | undefined { + if (!lowercaseTimeZoneLookup) { + lowercaseTimeZoneLookup = new Map(); + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + for (const zone of zones ?? []) lowercaseTimeZoneLookup.set(zone.toLowerCase(), zone); + } + return lowercaseTimeZoneLookup.get(val.toLowerCase()); +} +/** returns an error message, or undefined if `val` is a correctly cased IANA time zone name */ +function checkTimeZoneName(val: string): string | undefined { + if (/^[+-]\d/.test(val)) return 'Value must be an IANA time zone name, not a UTC offset'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: val }).resolvedOptions().timeZone; + } catch { + return `"${val}" is not a known IANA time zone`; + } + if (resolved === val) return; + const correctCase = findTimeZoneByCase(val) + ?? (resolved.toLowerCase() === val.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === val ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + // A legacy alias (e.g. `US/Eastern`) that the engine canonicalized to another zone. Engines + // do not expose alias casing, but every segment of every IANA name starts with an uppercase + // letter or digit, so a lowercase start is always wrong. + if (val.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${val}" is miscased (it is an alias of "${resolved}")`; + } +} +const TimeZoneDataType = createEnvGraphDataType({ + name: 'timeZone', + icon: 'mdi:map-clock-outline', + typeDescription: 'IANA time zone name (e.g. `Europe/Madrid`)', + validate(val) { + const error = checkTimeZoneName(val); + return error ? new ValidationError(error) : true; + }, +}); + // RFC 9562 versions 1-8 (captures the version digit), plus the special NIL and MAX UUIDs const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const NIL_UUID = '00000000-0000-0000-0000-000000000000'; @@ -1384,6 +1430,14 @@ const RecordDataType = createEnvGraphDataType((settings?: RecordDataTypeSettings }; }); +/** + * Old type names still accepted (with a deprecation warning) and the name that replaced them. + * TODO: drop in the next major. + */ +export const DEPRECATED_DATA_TYPE_ALIASES: Record = { + 'simple-object': 'simpleObject', +}; + export const BaseDataTypes: Array = [ StringDataType, NumberDataType, @@ -1397,6 +1451,7 @@ export const BaseDataTypes: Array = [ PortDataType, SemverDataType, IsoDateDataType, + TimeZoneDataType, UuidDataType, Md5DataType, DurationDataType, diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index c6a89a42d..5a3296279 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -7,7 +7,7 @@ import { EnvGraphDataSource, FileBasedDataSource, ImportAliasSource } from './da import { computeFilteredKeys, type ParsedItemFilter } from './item-filter'; import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver'; -import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types'; +import { BaseDataTypes, DEPRECATED_DATA_TYPE_ALIASES, type EnvGraphDataTypeFactory } from './data-types'; import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils'; import { ResolutionError, SchemaError, ValidationError } from './errors'; import { @@ -491,6 +491,9 @@ export class EnvGraph { for (const dataType of BaseDataTypes) { this.registerDataType(dataType); } + for (const [alias, name] of Object.entries(DEPRECATED_DATA_TYPE_ALIASES)) { + this.dataTypesRegistry[alias] = this.dataTypesRegistry[name]; + } // register base resolvers (concat, ref, exec, etc) for (const resolverClass of BaseResolvers) { this.registerResolver(resolverClass); diff --git a/packages/varlock/src/env-graph/lib/type-decorator.ts b/packages/varlock/src/env-graph/lib/type-decorator.ts index 58fd90ee4..89ac8ca38 100644 --- a/packages/varlock/src/env-graph/lib/type-decorator.ts +++ b/packages/varlock/src/env-graph/lib/type-decorator.ts @@ -7,7 +7,9 @@ import { ParsedEnvSpecStaticValue, type ParsedEnvSpecDecorator, } from '@env-spec/parser'; -import type { CoercedType, EnvGraphDataType, EnvGraphDataTypeFactory } from './data-types'; +import { + DEPRECATED_DATA_TYPE_ALIASES, type CoercedType, type EnvGraphDataType, type EnvGraphDataTypeFactory, +} from './data-types'; import { assertUnwrappedRegexSource, convertParsedValueToResolvers, deprecatedRegexStringWarning, type Resolver, @@ -98,10 +100,19 @@ function unknownDataTypeError(name: string, context?: string): SchemaError { return new SchemaError(`${context ? `${context} - ` : ''}unknown data type: ${name}`, { // `object` is the natural guess for what varlock calls `record` (reserved for a // possible future per-key shape type) - ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simple-object for untyped JSON objects' } : {}, + ...name === 'object' ? { tip: 'use record(...) for typed key/value maps, or simpleObject for untyped JSON objects' } : {}, }); } +function warnIfDeprecatedTypeName(ctx: TypeSpecContext, name: string, context?: string) { + const replacement = DEPRECATED_DATA_TYPE_ALIASES[name]; + if (!replacement) return; + pushWarningOnce(ctx.warnings, new SchemaError( + `${context ? `${context} - ` : ''}data type "${name}" is deprecated, use "${replacement}" instead`, + { isWarning: true, tip: 'it will be removed in the next major version' }, + )); +} + function typeSpecDisplayName(node: TypeSpecNode): string { return node instanceof ParsedEnvSpecFunctionCall ? node.name : String(node.value); } @@ -191,6 +202,7 @@ function buildBareTypePlan( context?: string, ): TypeSpecPlan { if (!(name in ctx.registry)) throw unknownDataTypeError(name, context); + warnIfDeprecatedTypeName(ctx, name, context); if (name === 'enum') throw emptyEnumError(context ?? '@type=enum'); return { deferred: [], build: () => ctx.registry[name]() }; } @@ -505,6 +517,7 @@ function buildTypeCallPlan( ): TypeSpecPlan { const name = fnCall.name; if (!(name in ctx.registry)) throw unknownDataTypeError(name, context); + warnIfDeprecatedTypeName(ctx, name, context); if (name === 'array') return buildArrayTypePlan(ctx, fnCall, context); if (name === 'record') return buildRecordTypePlan(ctx, fnCall, context); diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts index 016e2f5ac..472d29a3b 100644 --- a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts +++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons.gen.ts @@ -12,6 +12,7 @@ export const BUNDLED_ICONS: Record = { "material-symbols-light:category": "", "material-symbols:captive-portal": "", "mdi:identifier": "", + "mdi:map-clock-outline": "", "mdi:timer-outline": "", "mdi:web": "", "simple-icons:semver": "", diff --git a/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg new file mode 100644 index 000000000..42c0fc98b --- /dev/null +++ b/packages/varlock/src/env-graph/lib/type-generation/bundled-icons/mdi--map-clock-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/varlock/src/env-graph/test/composite-serialization.test.ts b/packages/varlock/src/env-graph/test/composite-serialization.test.ts index 4ba172782..cf84a75e6 100644 --- a/packages/varlock/src/env-graph/test/composite-serialization.test.ts +++ b/packages/varlock/src/env-graph/test/composite-serialization.test.ts @@ -187,10 +187,10 @@ describe('runtime re-injection of composite values (initVarlockEnv)', () => { }); }); -describe('simple-object serialization (issue #900)', () => { - it('simple-object values serialize as JSON, not "[object Object]"', async () => { +describe('simpleObject serialization (issue #900)', () => { + it('simpleObject values serialize as JSON, not "[object Object]"', async () => { const g = await loadAndResolve(outdent` - # @type=simple-object + # @type=simpleObject MY_OBJECT='{"key": "value"}' `); expect(g.configSchema.MY_OBJECT.resolvedValue).toEqual({ key: 'value' }); diff --git a/packages/varlock/src/env-graph/test/data-types.test.ts b/packages/varlock/src/env-graph/test/data-types.test.ts index d42e14fe8..6d48b4c25 100644 --- a/packages/varlock/src/env-graph/test/data-types.test.ts +++ b/packages/varlock/src/env-graph/test/data-types.test.ts @@ -956,6 +956,77 @@ describe('md5 data type', () => { }); }); +describe('deprecated data type names', () => { + it.each([ + '@type=simple-object', + '@type=array(simple-object)', + ])('%s still works but warns', async (decorator) => { + const g = await loadAndResolve(outdent` + # ${decorator} + OBJ='${decorator.includes('array') ? '[{"a": 1}]' : '{"a": 1}'}' + `); + const item = g.configSchema.OBJ; + expect(item.isValid).toBe(true); + expect(item.dataType?.name).toBe(decorator.includes('array') ? 'array' : 'simpleObject'); + const warnings = item.errors.filter((e) => (e as any).isWarning); + expect(warnings.map((w) => w.message).join('\n')).toContain('use "simpleObject" instead'); + }); +}); + +describe('timeZone data type', () => { + it.each([ + 'Europe/Madrid', + 'UTC', + 'America/Argentina/Buenos_Aires', + 'America/Buenos_Aires', // legacy alias + 'US/Eastern', // legacy alias, canonicalized to America/New_York on Node + 'Etc/UTC', + 'Etc/GMT+5', + ])('accepts %s', async (value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(true); + expect(g.configSchema.TZ.resolvedValue).toBe(value); + }); + + it.each([ + ['typo', 'Europe/Madird'], + ['UTC offset', '+05:00'], + ['compact UTC offset', '-0800'], + ['garbage', 'not a zone'], + ])('rejects %s', async (_label, value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ="${value}" + `); + expect(g.configSchema.TZ.isValid).toBe(false); + }); + + it.each([ + ['europe/madrid', 'Europe/Madrid'], + ['utc', 'UTC'], + ])('rejects wrong casing (%s) and suggests the correct name', async (value, suggestion) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(false); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain(`"${suggestion}"`); + }); + + // Node canonicalizes these to a different zone, so no casing suggestion is available + it.each(['us/eastern', 'etc/utc'])('rejects wrong-cased legacy alias %s', async (value) => { + const g = await loadAndResolve(outdent` + # @type=timeZone + TZ=${value} + `); + expect(g.configSchema.TZ.isValid).toBe(false); + expect(g.configSchema.TZ.validationErrors?.[0]?.message).toContain('case-sensitive'); + }); +}); + describe('domain data type', () => { it('accepts a basic domain', async () => { const g = await loadAndResolve(outdent` diff --git a/packages/varlock/src/env-graph/test/type-generation.test.ts b/packages/varlock/src/env-graph/test/type-generation.test.ts index b3ebc8951..5f460a6ec 100644 --- a/packages/varlock/src/env-graph/test/type-generation.test.ts +++ b/packages/varlock/src/env-graph/test/type-generation.test.ts @@ -1396,7 +1396,7 @@ describe('type generation', () => { FLAG=true # @type=boolean @public # @type=enum(dev, staging, prod) APP_ENV=dev # @public - # @type=simple-object + # @type=simpleObject CONFIG={} # @public `, }); diff --git a/packages/varlock/src/env-graph/test/type-generation/helpers.ts b/packages/varlock/src/env-graph/test/type-generation/helpers.ts index cf4059c07..213209033 100644 --- a/packages/varlock/src/env-graph/test/type-generation/helpers.ts +++ b/packages/varlock/src/env-graph/test/type-generation/helpers.ts @@ -20,7 +20,7 @@ export const NON_STRING_TYPE_FIXTURE = outdent` DEBUG=false # @optional @public # @type=enum(dev, staging, prod) APP_ENV=dev # @required @public - # @type=simple-object + # @type=simpleObject CONFIG={} # @optional @public API_KEY= # @required @sensitive `; diff --git a/packages/vscode-plugin/src/diagnostics-core.ts b/packages/vscode-plugin/src/diagnostics-core.ts index 3fa00d0e5..fed7c688b 100644 --- a/packages/vscode-plugin/src/diagnostics-core.ts +++ b/packages/vscode-plugin/src/diagnostics-core.ts @@ -628,6 +628,32 @@ export function getStaticValueWarning(typeInfo: TypeInfo, value: string) { return undefined; } +// Mirrors the runtime `timeZone` validator in packages/varlock/src/env-graph/lib/data-types.ts. +let lowercaseTimeZoneLookup: Map | undefined; +function checkTimeZoneName(value: string): string | undefined { + if (/^[+-]\d/.test(value)) return 'Value must be an IANA time zone name, not a UTC offset.'; + let resolved: string; + try { + resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; + } catch { + return 'Value must be a known IANA time zone.'; + } + if (resolved === value) return undefined; + if (!lowercaseTimeZoneLookup) { + const zones = (Intl as any).supportedValuesOf?.('timeZone') as Array | undefined; + lowercaseTimeZoneLookup = new Map((zones ?? []).map((zone) => [zone.toLowerCase(), zone])); + } + const correctCase = lowercaseTimeZoneLookup.get(value.toLowerCase()) + ?? (resolved.toLowerCase() === value.toLowerCase() ? resolved : undefined); + if (correctCase) { + return correctCase === value ? undefined : `Time zone names are case-sensitive, did you mean "${correctCase}"?`; + } + if (value.split('/').some((segment) => /^[a-z]/.test(segment))) { + return `Time zone names are case-sensitive, "${value}" is miscased (it is an alias of "${resolved}").`; + } + return undefined; +} + export function validateStaticValue(typeInfo: TypeInfo, value: string) { switch (typeInfo.name) { case 'string': @@ -676,6 +702,8 @@ export function validateStaticValue(typeInfo: TypeInfo, value: string) { return /^\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+-Z]*)?$/.test(value) && !Number.isNaN(Date.parse(value)) ? undefined : 'Value must be a valid ISO date.'; + case 'timeZone': + return checkTimeZoneName(value); case 'uuid': { const version = typeInfo.options.version; const detectedVersion = /^[0-9a-f]{8}-[0-9a-f]{4}-([1-8])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.exec(value)?.[1]; diff --git a/packages/vscode-plugin/src/intellisense-catalog.ts b/packages/vscode-plugin/src/intellisense-catalog.ts index c443c3017..efafd67ef 100644 --- a/packages/vscode-plugin/src/intellisense-catalog.ts +++ b/packages/vscode-plugin/src/intellisense-catalog.ts @@ -370,10 +370,10 @@ export const DATA_TYPES: Array = [ ], }, { - name: 'simple-object', + name: 'simpleObject', summary: 'JSON-like object value.', documentation: 'Coerces plain objects or JSON strings into objects.', - insertText: 'simple-object', + insertText: 'simpleObject', }, { name: 'enum', @@ -446,6 +446,12 @@ export const DATA_TYPES: Array = [ documentation: 'Supports date strings with optional time and milliseconds.', insertText: 'isoDate', }, + { + name: 'timeZone', + summary: 'IANA time zone name.', + documentation: 'Validates names like `Europe/Madrid` or `UTC` against the runtime time zone database.', + insertText: 'timeZone', + }, { name: 'uuid', summary: 'UUID string.', diff --git a/packages/vscode-plugin/test/diagnostics-core.test.ts b/packages/vscode-plugin/test/diagnostics-core.test.ts index 708566a69..d96ae8b74 100644 --- a/packages/vscode-plugin/test/diagnostics-core.test.ts +++ b/packages/vscode-plugin/test/diagnostics-core.test.ts @@ -543,6 +543,15 @@ describe('diagnostics-core', () => { ), ).toBe('Value must be a valid IPv4 address.'); + const timeZoneType = { name: 'timeZone', args: [], options: {} }; + expect(validateStaticValue(timeZoneType, 'Europe/Madrid')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'US/Eastern')).toBeUndefined(); + expect(validateStaticValue(timeZoneType, 'Europe/Madird')).toBe('Value must be a known IANA time zone.'); + expect(validateStaticValue(timeZoneType, '+05:00')).toBe('Value must be an IANA time zone name, not a UTC offset.'); + expect(validateStaticValue(timeZoneType, 'europe/madrid')).toBe('Time zone names are case-sensitive, did you mean "Europe/Madrid"?'); + expect(validateStaticValue(timeZoneType, 'utc')).toBe('Time zone names are case-sensitive, did you mean "UTC"?'); + expect(validateStaticValue(timeZoneType, 'us/eastern')).toContain('case-sensitive'); + expect( validateStaticValue( {