import { Plugin, type Shape, type Result, type Diagnostics, CallExpressionShape, DurationShape, TimeShape, FieldListShape, TemplateValueShape, DATA_HELPER_NAMES, ts, } from '@servicenow/sdk-build-core' import { getCallExpressionName } from './utils' /** * Check if a ts.Node is a data helper call expression. */ export function isDataHelper(node: ts.Node): boolean { if (!ts.Node.isCallExpression(node)) { return false } const callee = getCallExpressionName(node) return (Object.values(DATA_HELPER_NAMES) as string[]).includes(callee) } /** * Shape creators for each data helper. * Maps helper function names to their shape creation logic. * All data helper shapes extend CallExpressionShape. */ const shapeCreators: Record< string, (shape: CallExpressionShape, diagnostics: Diagnostics) => Result > = { [DATA_HELPER_NAMES.DURATION]: createDurationShape, [DATA_HELPER_NAMES.TIME]: createTimeShape, [DATA_HELPER_NAMES.FIELD_LIST]: createFieldListShape, [DATA_HELPER_NAMES.TEMPLATE_VALUE]: createTemplateValueShape, } export const DataPlugin = Plugin.create({ name: 'DataPlugin', shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], toSubclass(shape, { diagnostics }) { const callee = shape.getCallee() const shapeCreator = shapeCreators[callee] // Only handle known data helper callees (Duration, Time, FieldList, TemplateValue) if (!shapeCreator) { return { success: false } } return shapeCreator(shape, diagnostics) }, }, ], }) /** * Helper to validate that all defined fields are non-negative. * Returns errors for negative values */ function validateNonNegative(data: Record, fields: string[]): string[] { const errors: string[] = [] for (const field of fields) { const value = data[field] if (!value?.isDefined() || !value.isNumber()) { continue // Skip undefined or non-numeric (will be caught by constructor) } const numericValue = value.asNumber().getValue() if (numericValue < 0) { errors.push(`${field} must be >= 0, got ${numericValue}`) } } return errors } /** * Helper to validate that at least one field is defined. * Returns true if at least one field has a defined value. */ function hasAtLeastOneField(data: Record, fields: string[]): boolean { return fields.some((field) => data[field]?.isDefined()) } /** * Creates a DurationShape from a CallExpressionShape. * Validates that duration values are within acceptable ranges. */ function createDurationShape(shape: CallExpressionShape, diagnostics: Diagnostics): Result { const argShape = shape.getArgument(0) const duration = argShape.asObject().properties({ resolve: false }) // Validate at least one field is provided if (!hasAtLeastOneField(duration, ['days', 'hours', 'minutes', 'seconds'])) { diagnostics.error(shape, 'Duration must have at least one field defined (days, hours, minutes, or seconds)') return { success: false } } const errors = validateNonNegative(duration, ['days', 'hours', 'minutes', 'seconds']) if (errors.length > 0) { diagnostics.error(shape, `Invalid duration values: ${errors.join(', ')}`) return { success: false } } return { success: true, value: new DurationShape({ source: shape, value: argShape.asObject() }) } } /** * Creates a TimeShape from a CallExpressionShape. * Validates that time values are within acceptable ranges. */ function createTimeShape(shape: CallExpressionShape, diagnostics: Diagnostics): Result { const argShape = shape.getArgument(0) const timeData = argShape.asObject().properties({ resolve: false }) // Validate at least one field is provided if (!hasAtLeastOneField(timeData, ['hours', 'minutes', 'seconds'])) { diagnostics.error(shape, 'Time must have at least one field defined (hours, minutes, or seconds)') return { success: false } } const errors = validateNonNegative(timeData, ['hours', 'minutes', 'seconds']) if (errors.length > 0) { diagnostics.error(shape, `Invalid time values: ${errors.join(', ')}`) return { success: false } } // Extract and validate optional timezone from second argument const tzArg = shape.getArgument(1) if (tzArg.isUndefined()) { return { success: true, value: new TimeShape({ source: shape, value: argShape.asObject() }) } } if (!tzArg.isString()) { diagnostics.error(shape, 'Second argument (timezone) must be a string literal') return { success: false } } const timeZone = tzArg.asString().getValue() if (!isValidTimeZone(timeZone)) { diagnostics.error( shape, `Invalid IANA timezone: '${timeZone}'. Use a valid timezone like 'America/New_York', 'Europe/London', or 'UTC'.` ) return { success: false } } return { success: true, value: new TimeShape({ source: shape, value: argShape.asObject(), timeZone }) } } /** * Creates a FieldListShape from a CallExpressionShape. */ function createFieldListShape(shape: CallExpressionShape, _diagnostics: Diagnostics): Result { const fields = shape.getArgument(0).asArray().getElements() return { success: true, value: new FieldListShape({ source: shape, fields }) } } /** * Creates a TemplateValueShape from a CallExpressionShape. */ function createTemplateValueShape(shape: CallExpressionShape, diagnostics: Diagnostics): Result { const value = shape.getArgument(0).asObject().properties({ resolve: false }) // Validate that values don't contain ^^ (double caret) for (const [key, val] of Object.entries(value)) { if (val.isString() && val.getValue().includes('^^')) { diagnostics.error( shape, `Template field '${key}' contains an invalid character sequence: multiple carets (^^). Use a single caret (^) if needed, it will be escaped automatically.` ) return { success: false } } } return { success: true, value: new TemplateValueShape({ source: shape, value }) } } /** * Validates if a timezone string is a valid IANA timezone. */ function isValidTimeZone(timeZone: string): boolean { try { Intl.DateTimeFormat(undefined, { timeZone }) return true } catch { return false } }