From 3326fc2326ed1edf42965aa341a453f45127bdbc Mon Sep 17 00:00:00 2001 From: Silvia Chen Date: Thu, 30 Jan 2025 16:24:27 -0800 Subject: [PATCH 1/2] Support JSON doc autocomplete for variables and jsonata expression --- package-lock.json | 25 + package.json | 2 + src/asl-utils/asl/asl.ts | 264 ++++++ src/asl-utils/asl/definitions.ts | 614 +++++++++++++ src/asl-utils/asl/tests/branch.test.ts | 34 + src/asl-utils/asl/tests/definitions.test.ts | 331 +++++++ .../asl/tests/getAllChildren.test.ts | 178 ++++ src/asl-utils/asl/tests/next.test.ts | 36 + src/asl-utils/asl/tests/visit.test.ts | 392 ++++++++ src/asl-utils/index.ts | 10 + src/asl-utils/utils/autocomplete.ts | 374 ++++++++ src/asl-utils/utils/jsonata/functions.ts | 844 ++++++++++++++++++ src/asl-utils/utils/jsonata/index.ts | 7 + src/asl-utils/utils/jsonata/jsonata.ts | 170 ++++ .../utils/tests/assignVariableTestData.ts | 302 +++++++ .../utils/tests/autocomplete.test.ts | 448 ++++++++++ src/asl-utils/utils/tests/jsonata.test.ts | 424 +++++++++ src/asl-utils/utils/tests/utils.test.ts | 93 ++ src/asl-utils/utils/utils.ts | 41 + src/completion/completeAsl.ts | 45 +- src/completion/completeJSONata.ts | 283 ++++++ src/completion/completeVariables.ts | 122 +++ src/completion/utils/jsonataUtils.ts | 68 ++ src/completion/utils/variableUtils.ts | 52 ++ src/service.ts | 3 +- src/tests/aslUtilityFunctions.test.ts | 143 ++- src/tests/completion.test.ts | 243 ++++- src/tests/json-strings/completionStrings.ts | 2 +- src/tests/json-strings/jsonataStrings.ts | 67 ++ src/tests/json-strings/variableStrings.ts | 92 ++ src/tests/jsonSchemaAsl.test.ts | 1 - src/tests/validation.test.ts | 20 +- src/utils/astUtilityFunctions.ts | 56 +- src/validation/utils/getDiagnosticsForNode.ts | 2 +- src/validation/validateProperties.ts | 3 - src/validation/validateStates.ts | 3 - src/yaml/aslYamlLanguageService.ts | 4 +- 37 files changed, 5741 insertions(+), 57 deletions(-) create mode 100644 src/asl-utils/asl/asl.ts create mode 100644 src/asl-utils/asl/definitions.ts create mode 100644 src/asl-utils/asl/tests/branch.test.ts create mode 100644 src/asl-utils/asl/tests/definitions.test.ts create mode 100644 src/asl-utils/asl/tests/getAllChildren.test.ts create mode 100644 src/asl-utils/asl/tests/next.test.ts create mode 100644 src/asl-utils/asl/tests/visit.test.ts create mode 100644 src/asl-utils/index.ts create mode 100644 src/asl-utils/utils/autocomplete.ts create mode 100644 src/asl-utils/utils/jsonata/functions.ts create mode 100644 src/asl-utils/utils/jsonata/index.ts create mode 100644 src/asl-utils/utils/jsonata/jsonata.ts create mode 100644 src/asl-utils/utils/tests/assignVariableTestData.ts create mode 100644 src/asl-utils/utils/tests/autocomplete.test.ts create mode 100644 src/asl-utils/utils/tests/jsonata.test.ts create mode 100644 src/asl-utils/utils/tests/utils.test.ts create mode 100644 src/asl-utils/utils/utils.ts create mode 100644 src/completion/completeJSONata.ts create mode 100644 src/completion/completeVariables.ts create mode 100644 src/completion/utils/jsonataUtils.ts create mode 100644 src/completion/utils/variableUtils.ts create mode 100644 src/tests/json-strings/jsonataStrings.ts create mode 100644 src/tests/json-strings/variableStrings.ts diff --git a/package-lock.json b/package-lock.json index 4a970325a..d1e455812 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "js-yaml": "^4.1.0", + "jsonata": "2.0.5", + "lodash": "^4.17.21", "vscode-json-languageservice": "3.4.9", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.0", @@ -5275,6 +5277,14 @@ "node": ">=6" } }, + "node_modules/jsonata": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.5.tgz", + "integrity": "sha512-wEse9+QLIIU5IaCgtJCPsFi/H4F3qcikWzF4bAELZiRz08ohfx3Q6CjDRf4ZPF5P/92RI3KIHtb7u3jqPaHXdQ==", + "engines": { + "node": ">= 8" + } + }, "node_modules/jsonc-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", @@ -5639,6 +5649,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11217,6 +11232,11 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonata": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.5.tgz", + "integrity": "sha512-wEse9+QLIIU5IaCgtJCPsFi/H4F3qcikWzF4bAELZiRz08ohfx3Q6CjDRf4ZPF5P/92RI3KIHtb7u3jqPaHXdQ==" + }, "jsonc-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", @@ -11451,6 +11471,11 @@ "p-locate": "^5.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/package.json b/package.json index f19a233e3..62f1525de 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ }, "dependencies": { "js-yaml": "^4.1.0", + "jsonata": "2.0.5", + "lodash": "^4.17.21", "vscode-json-languageservice": "3.4.9", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.0", diff --git a/src/asl-utils/asl/asl.ts b/src/asl-utils/asl/asl.ts new file mode 100644 index 000000000..2e9f5e719 --- /dev/null +++ b/src/asl-utils/asl/asl.ts @@ -0,0 +1,264 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { + Asl, + AslWithStates, + DistributedMapState, + getProcessorDefinition, + isAslWithStates, + isChoice, + isMap, + isParallel, + isTerminal, + NextOrEnd, + StateDefinition, + StateId, +} from './definitions' +import { lastItem } from '../utils/utils' + +export type StateIdOrBranchIndex = StateId | number + +/** + * Path to a state. + * Each item can be a key (string) or item index (number). + */ +export type StatePath = [] | [...StateIdOrBranchIndex[], StateId] + +/** + * Path to a branch. + */ +export type BranchPath = [] | StateIdOrBranchIndex[] + +export type StateAddress = { + state: T | null + path: StatePath | null + parent: AslWithStates | null + parentPath: BranchPath | null +} + +export type LocatedState = { + state: T + parent: AslWithStates + path: StatePath +} + +export const STATE_NOT_FOUND = { state: null, parent: null, path: null, parentPath: null } + +/** + * A function which is called for each state of an ASL. + * + * @return true to indicate visiting should continue, false to stop further visiting + * @see {@link visitAllStates}. + */ +type StateVisitor = ( + id: StateId, + state: T, + parent: AslWithStates, + path: StatePath, +) => boolean + +/** + * Visits all States in the given ASL and for each one calls fn with id, state and parent parameters. + * When fn returns false the visiting stops. + */ +export function visitAllStates(asl: Asl, fn: StateVisitor): void { + if (asl.States) { + visitAllStatesInBranch(asl, [], fn) + } +} + +/** + * In the scope of an ASL searches for the given stateId and returns the state and the state's parent. + * parent is the branch which contains the state (i.e. you can find the given id in parent.States). + */ +export function findStateById( + asl: Asl, + stateId: StateId, + branchPath: BranchPath = [], +): StateAddress { + if (!isAslWithStates(asl)) { + return STATE_NOT_FOUND + } + + const state = asl.States[stateId] + if (state) { + return { state, parent: asl, parentPath: branchPath, path: [...branchPath, stateId] } + } + + for (const [childStateId, childState] of Object.entries(asl.States)) { + if (isMap(childState)) { + const iteratorProcessor = getProcessorDefinition>(childState as DistributedMapState) + + const result = findStateById(iteratorProcessor, stateId, [...branchPath, childStateId]) + if (result.state != null) { + return result + } + } + + if (isParallel(childState) && childState.Branches) { + for (let branchIndex = 0; branchIndex < childState.Branches.length; branchIndex++) { + const branch = childState.Branches[branchIndex] + const result = findStateById(branch, stateId, [...branchPath, childStateId, branchIndex]) + if (result.state != null) { + return result + } + } + } + } + + return STATE_NOT_FOUND +} + +/** + * Returns all states which are children of the given stateId at any depth. + */ +export function getAllChildren(asl: Asl, stateId: StateId): StateId[] { + const { state } = findStateById(asl, stateId) + if (state === null) { + return [] + } + + return getAllChildrenOfState(state) +} + +function getAllChildrenOfState(state: StateDefinition | Asl): StateId[] { + const result: StateId[] = [] + + if ('States' in state && state.States) { + for (const childId of Object.keys(state.States)) { + result.push(childId) + const childState: StateDefinition = state.States[childId] + result.push(...getAllChildrenOfState(childState)) + } + } + + if ('Branches' in state && state.Branches) { + // parallel + for (const branch of state.Branches) { + result.push(...getAllChildrenOfState(branch)) + } + } + + if ('Iterator' in state && state.Iterator) { + result.push(...getAllChildrenOfState(state.Iterator)) + } + + if ('ItemProcessor' in state && state.ItemProcessor) { + result.push(...getAllChildrenOfState(state.ItemProcessor)) + } + + return result +} + +/** + * Returns the state which is directly after the given one. + * For ErrorHandled states it means the Next (or End) property as opposed to any Catches + * For Choice it is Default or if missing the first choice rule's Next + */ +export function getDirectNext(state: StateDefinition): NextOrEnd { + if (isTerminal(state)) { + return null + } + + if (isChoice(state)) { + if (state.Default) { + return state.Default + } + + if (state.Choices && state.Choices.length >= 1) { + return state.Choices[0].Next || null + } + + return null + } + + return state.Next || null +} + +/** + * Returns all the state ids anywhere in the given ASL. + */ +export function getAllStateIds(asl: Asl): StateId[] { + const result: StateId[] = [] + visitAllStates(asl, (id) => { + result.push(id) + return true + }) + return result +} + +export function isParallelBranch(path: BranchPath | null): boolean { + return path !== null && path.length > 1 && typeof lastItem(path) === 'number' +} + +export function visitAllStatesInBranch( + parent: Asl, + partialPath: StateIdOrBranchIndex[], + fn: StateVisitor, +): boolean { + if (isAslWithStates(parent)) { + for (const [id, state] of Object.entries(parent.States)) { + const path: StatePath = [...partialPath, id] + // returning false indicates halting the visit to the rest + const shouldContinueTheVisit = fn(id, state, parent, path) + if (!shouldContinueTheVisit) { + return false + } + + if ('Iterator' in state && state.Iterator && state.Iterator.States) { + const shouldContinue = visitAllStatesInBranch(state.Iterator as T, path, fn) + if (!shouldContinue) { + return false + } + } + if ('ItemProcessor' in state && state.ItemProcessor && state.ItemProcessor.States) { + const shouldContinue = visitAllStatesInBranch(state.ItemProcessor as T, path, fn) + if (!shouldContinue) { + return false + } + } + + if ('Branches' in state && state.Branches) { + for (let branchIndex = 0; branchIndex < state.Branches.length; branchIndex++) { + const branch = state.Branches[branchIndex] + const shouldContinue = visitAllStatesInBranch(branch as T, [...path, branchIndex], fn) + if (!shouldContinue) { + return false + } + } + } + } + } + + // true means continue visiting other states + return true +} + +/** + * Get state ID given a branch path + */ +export function getStateIdFromBranchPath(path: BranchPath): StateId | undefined { + for (let i = path.length - 1; i >= 0; i--) { + const stateIdOrIndex = path[i] + if (typeof stateIdOrIndex === 'string') { + return stateIdOrIndex + } + } +} + +/** + * Get branch index given the branch name + * @param branchName Branches[3] + * @returns 3 + */ +export function getBranchIndex(branchName: string): number | null { + const pattern = /^Branches\[(\d+)\]$/ + const match = branchName.match(pattern) + if (match) { + return parseInt(match[1], 10) + } + return null +} diff --git a/src/asl-utils/asl/definitions.ts b/src/asl-utils/asl/definitions.ts new file mode 100644 index 000000000..34095e6f7 --- /dev/null +++ b/src/asl-utils/asl/definitions.ts @@ -0,0 +1,614 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +export type JSONataExpression = string + +/** + * Reference to a state to be used as Next. If null then pointer to End. + */ +export type NextOrEnd = StateId | null + +export enum QueryLanguages { + JSONPath = 'JSONPath', + JSONata = 'JSONata', +} + +export interface Asl { + Comment?: string + Version?: string + TimeoutSeconds?: number | JSONataExpression + + StartAt?: StateId + States?: StatesSet + QueryLanguage?: QueryLanguages +} + +export enum StateMachineType { + Standard = 'STANDARD', + Express = 'EXPRESS', +} + +/** + * An {@link Asl} which is guaranteed to have States property. + * It is used to reduce the number of null checks and unit tests branches to cover the null checks and replace them with + * more elegant compile-time checks. + */ +export interface AslWithStates extends Asl { + States: StatesSet +} + +export type StateType = + | 'Pass' + | 'Wait' + | 'Task' + | 'Succeed' + | 'Fail' + | 'Choice' + | 'Map' + | 'Parallel' + | 'Placeholder' + | 'Snippet' + +export type StatesSet = { [id: string]: T } +export type StateId = string + +export type JsonPrimitive = null | string | number | boolean | JSONataExpression +export type JsonArray = (JsonPrimitive | JsonMap | JsonArray)[] +export type JsonMap = { [key: string]: JsonObject } +export type JsonObject = JsonMap | JsonArray | JsonPrimitive + +export interface BaseState { + Type: StateType + Comment?: string + InputPath?: string | null + OutputPath?: string | null + Output?: JsonMap + ResultPath?: string | null + ResultSelector?: JsonObject + QueryLanguage?: QueryLanguages +} + +export enum StatePointers { + Next = 'Next', + End = 'End', +} + +export interface StatePointer { + [StatePointers.Next]?: StateId + [StatePointers.End]?: boolean +} + +export interface PassState extends StatePointer, WithParametersBase, VariableBase, BaseState { + Type: 'Pass' + Result?: JsonObject +} + +export interface WaitState extends StatePointer, BaseState, VariableBase { + Type: 'Wait' + Seconds?: number | JSONataExpression + SecondsPath?: string + Timestamp?: string + TimestampPath?: string +} + +export interface DeprecatedMapState + extends ErrorHandledBase, + StatePointer, + WithParametersBase, + VariableBase, + BaseState { + Type: 'Map' + Iterator?: Asl + ItemsPath?: string + Items?: JSONataExpression | JsonArray + MaxConcurrency?: number | JSONataExpression +} + +/** + * A {@link DeprecatedMapState} which is guaranteed to have Branches property. + * {@see AslWith} + */ +export interface DeprecatedMapWithIterator extends DeprecatedMapState { + Iterator: Asl +} + +export interface ItemBatcher { + BatchInput: { + [item: string]: any + } + MaxItemsPerBatch?: number | JSONataExpression + MaxInputBytesPerBatch?: number | JSONataExpression + MaxItemsPerBatchPath?: string + MaxInputBytesPerBatchPath?: string +} + +export interface ResultWriter { + Resource: string + Parameters?: { + Bucket?: string + Key?: string + Prefix?: string + 'Bucket.$'?: string + 'Key.$'?: string + 'Prefix.$'?: string + } + Arguments?: + | { + Bucket?: string + Key?: string + Prefix?: string + ExpectedBucketOwner?: string + } + | JSONataExpression +} + +export interface ItemReader { + ReaderConfig?: { + InputType?: ParsingInputType + CSVHeaderLocation?: CSVHeaderLocationType + MaxItems?: number | JSONataExpression + MaxItemsPath?: string + CSVHeaders?: string[] + } + Resource?: string + Parameters?: { + Bucket?: string + Key?: string + Prefix?: string + ExpectedBucketOwner?: string + 'Bucket.$'?: string + 'Key.$'?: string + 'Prefix.$'?: string + 'ExpectedBucketOwner.$'?: string + } + Arguments?: + | { + Bucket?: string + Key?: string + Prefix?: string + ExpectedBucketOwner?: string + } + | JSONataExpression +} + +export enum DistributedMapProcessingMode { + Distributed = 'DISTRIBUTED', + Inline = 'INLINE', +} +export enum MapExecutionType { + Express = 'EXPRESS', + Standard = 'STANDARD', +} + +export enum ParsingInputType { + CSV = 'CSV', + JSON = 'JSON', + MANIFEST = 'MANIFEST', +} + +export enum CSVHeaderLocationType { + FIRST_ROW = 'FIRST_ROW', + GIVEN = 'GIVEN', +} + +export interface ProcessorConfig { + Mode: DistributedMapProcessingMode + ExecutionType?: MapExecutionType +} + +export type ItemProcessor = Asl & { ProcessorConfig?: ProcessorConfig } + +export const isInlineMap = (state: DistributedMapState): boolean => { + return ( + getProcessorDefinition(state).ProcessorConfig === undefined || + getProcessorDefinition(state).ProcessorConfig?.Mode === DistributedMapProcessingMode.Inline + ) +} + +export interface DistributedMapStateItemProcessor + extends ErrorHandledBase, + StatePointer, + BaseState, + VariableBase { + Type: 'Map' + ItemProcessor: ItemProcessor + ItemBatcher?: ItemBatcher + ItemReader?: ItemReader + ResultWriter?: ResultWriter + ItemsPath?: string + Items?: JSONataExpression | JsonArray + ItemSelector?: Record + Parameters?: Record + MaxConcurrency?: number | JSONataExpression + ToleratedFailurePercentage?: number | JSONataExpression + ToleratedFailureCount?: number | JSONataExpression + Label?: string + 'Label.$'?: string +} +export interface DistributedMapStateIterator + extends ErrorHandledBase, + StatePointer, + BaseState, + VariableBase { + Type: 'Map' + Iterator: ItemProcessor + ItemBatcher?: ItemBatcher + ItemReader?: ItemReader + ResultWriter?: ResultWriter + ItemsPath?: string + Items?: JSONataExpression | JsonArray + ItemSelector?: Record + Parameters?: Record + MaxConcurrency?: number | JSONataExpression + ToleratedFailurePercentage?: number | JSONataExpression + ToleratedFailureCount?: number | JSONataExpression + Label?: string + 'Label.$'?: string +} +export const isDistributedMapStateItemProcessor = ( + distributedMap: DistributedMapState, +): distributedMap is DistributedMapStateItemProcessor => { + return (distributedMap as DistributedMapStateItemProcessor).ItemProcessor !== undefined +} +export const getProcessorDefinition = >( + distributedMap: V, +): ItemProcessor => { + if (isDistributedMapStateItemProcessor(distributedMap)) { + return distributedMap.ItemProcessor + } else { + return distributedMap.Iterator + } +} + +export const getProcessorFieldName = >( + distributedMap: V, +): string => { + if ('Iterator' in distributedMap) { + return 'Iterator' + } else { + return 'ItemProcessor' + } +} + +export const getItemSelectorDefinition = >( + distributedMap: V, +): Record | undefined => { + if ('Parameters' in distributedMap) { + return distributedMap.Parameters + } else { + return distributedMap.ItemSelector + } +} + +export const getItemSelectorFieldName = >( + distributedMap: V, +): string => { + if ('Parameters' in distributedMap) { + return 'Parameters' + } else { + return 'ItemSelector' + } +} + +export type DistributedMapState = + | DistributedMapStateItemProcessor + | DistributedMapStateIterator +export type MapState = + | DeprecatedMapState + | DistributedMapStateItemProcessor + | DistributedMapStateIterator + +export interface ParallelState + extends ErrorHandledBase, + StatePointer, + WithParametersBase, + VariableBase, + BaseState { + Type: 'Parallel' + Branches?: Asl[] + ResultPath?: string +} + +/** + * A {@link ParallelState} which is guaranteed to have Branches property. + * {@see AslWith} + */ +export interface ParallelWithBranches extends ParallelState { + Branches: Asl[] +} + +export interface ChoiceState extends BaseState, VariableBase { + Type: 'Choice' + Choices?: ChoiceRule[] + Default?: StateId +} + +/** + * A {@link ChoiceState} which is guaranteed to have Choices property. + * {@see AslWith} + */ +export interface ChoiceWithChoices extends ChoiceState { + Choices: ChoiceRule[] +} + +export interface Comparison { + Variable?: string + + BooleanEquals?: boolean + BooleanEqualsPath?: string + + IsBoolean?: boolean + IsNull?: boolean + IsNumeric?: boolean + IsPresent?: boolean + IsString?: boolean + IsTimestamp?: boolean + + NumericEquals?: number + NumericEqualsPath?: string + NumericGreaterThan?: number + NumericGreaterThanPath?: string + NumericGreaterThanEquals?: number + NumericGreaterThanEqualsPath?: string + NumericLessThan?: number + NumericLessThanPath?: string + NumericLessThanEquals?: number + NumericLessThanEqualsPath?: string + + StringEquals?: string + StringEqualsPath?: string + StringGreaterThan?: string + StringGreaterThanPath?: string + StringGreaterThanEquals?: string + StringGreaterThanEqualsPath?: string + StringLessThan?: string + StringLessThanPath?: string + StringLessThanEquals?: string + StringLessThanEqualsPath?: string + + StringMatches?: string + + TimestampEquals?: string + TimestampEqualsPath?: string + TimestampGreaterThan?: string + TimestampGreaterThanPath?: string + TimestampGreaterThanEquals?: string + TimestampGreaterThanEqualsPath?: string + TimestampLessThan?: string + TimestampLessThanPath?: string + TimestampLessThanEquals?: string + TimestampLessThanEqualsPath?: string + + Not?: Comparison + And?: Comparison[] + Or?: Comparison[] +} + +export type ChoiceRule = ChoiceRuleV1 | ChoiceRuleV2 +export interface ChoiceRuleV1 extends Rule, Comparison, VariableBase { + Next?: StateId +} + +export interface ChoiceRuleV2 extends Rule, VariableBase { + Condition?: boolean | JSONataExpression + Next?: StateId +} + +export interface VariableBase { + Assign?: JsonMap +} + +export interface WithParametersBase { + Parameters?: JsonObject + Arguments?: JsonObject +} + +export type WithParameters = TaskState | DeprecatedMapState | ParallelState | PassState +export type WithVariables = PassState | WaitState | TaskState | ChoiceState | MapState | ParallelState +export type WithErrorHandled = TaskState | MapState | ParallelState + +export interface TaskState extends ErrorHandledBase, StatePointer, WithParametersBase, VariableBase, BaseState { + Type: 'Task' + Resource?: string + ResultPath?: string | null + OutputPath?: string | null + TimeoutSeconds?: number | JSONataExpression + HeartbeatSeconds?: number | JSONataExpression + TimeoutSecondsPath?: string + HeartbeatSecondsPath?: string + Credentials?: { + RoleArn?: string + 'RoleArn.$'?: string + } + RateControl?: { + Arn?: string + 'Arn.$'?: string + } +} + +export type TerminalState = SucceedState | FailState +export interface SucceedState extends BaseState { + Type: 'Succeed' +} + +export interface FailState extends BaseState { + Type: 'Fail' + Cause?: string + Error?: string | null + ErrorPath?: string + CausePath?: string +} + +export interface PlaceholderState extends StatePointer, BaseState { + Type: 'Placeholder' + PlaceholderLabel: string +} + +export type StateDefinition = + | PassState + | WaitState + | TaskState + | SucceedState + | FailState + | ChoiceState + | MapState + | ParallelState + | PlaceholderState + +export type NonTerminalState = + | PassState + | WaitState + | TaskState + | ChoiceState + | MapState + | ParallelState + | PlaceholderState + +export type StateWithPointer = Required + +export interface Commented { + Comment?: string +} + +export type Rule = Commented + +export interface ErrorRule extends Rule { + ErrorEquals?: string[] +} + +export interface ErrorHandledBase { + Catch?: CatchRule[] + Retry?: RetryRule[] +} + +export type ErrorHandled = TaskState | MapState | ParallelState + +export interface CatchRule extends StatePointer, ErrorRule, VariableBase { + ResultPath?: string | null + Output?: JsonMap +} + +export interface RetryRule extends ErrorRule { + IntervalSeconds?: number + MaxAttempts?: number + BackoffRate?: number + JitterStrategy?: string + MaxDelaySeconds?: number +} + +// type guards + +export function isPass(state: StateDefinition): state is PassState { + return state.Type === 'Pass' +} + +export function isTerminal(state: StateDefinition): state is TerminalState { + return state.Type === 'Succeed' || state.Type === 'Fail' +} + +export function isNonTerminal(state: StateDefinition): state is NonTerminalState { + return !isTerminal(state) +} + +export function isWait(state: StateDefinition): state is WaitState { + return state.Type === 'Wait' +} + +export function isFail(state: StateDefinition): state is FailState { + return state.Type === 'Fail' +} + +export function isSucceed(state: StateDefinition): state is SucceedState { + return state.Type === 'Succeed' +} + +export function isTask(state: StateDefinition): state is TaskState { + return state.Type === 'Task' +} + +export function isChoice(state: StateDefinition): state is ChoiceState { + return state.Type === 'Choice' +} + +export function isMap(state: StateDefinition): state is MapState { + return state.Type === 'Map' +} + +export function isDistributedMap(state: StateDefinition): state is DistributedMapState { + return isMap(state) +} + +export function isDistributedMode(state: DistributedMapState): boolean { + return getProcessorDefinition(state).ProcessorConfig?.Mode === DistributedMapProcessingMode.Distributed +} + +export function isParallel( + state: StateDefinition, +): state is ParallelState { + return state.Type === 'Parallel' +} + +export function isPlaceholder(state: StateDefinition): state is PlaceholderState { + return state.Type === 'Placeholder' +} + +export function isValidStateType(state: StateDefinition): boolean { + return ( + isWait(state) || + isFail(state) || + isSucceed(state) || + isPass(state) || + isChoice(state) || + isTask(state) || + isMap(state) || + isParallel(state) + ) +} + +export function isErrorHandled(state: StateDefinition): state is T { + return state.Type === 'Task' || state.Type === 'Map' || state.Type === 'Parallel' +} + +export function isWithParameters(state: StateDefinition): state is WithParameters { + return state.Type === 'Task' || state.Type === 'Map' || state.Type === 'Parallel' || state.Type === 'Pass' +} + +export function isJsonMap(obj: any): obj is JsonMap { + return typeof obj === 'object' && !Array.isArray(obj) && obj !== null +} + +export function isAslWithStates( + asl: Asl | undefined | null, +): asl is AslWithStates { + return !!asl && !!asl.States && typeof asl.States === 'object' && !Array.isArray(asl.States) +} + +export function isChoiceWithChoices( + state: StateDefinition | undefined | null, +): state is T { + return !!state && state.Type === 'Choice' && !!state.Choices && Array.isArray(state.Choices) +} + +export function isMapWithIterator(state: StateDefinition | undefined | null): state is DeprecatedMapWithIterator { + return ( + !!state && + state.Type === 'Map' && + !!(state as DeprecatedMapState).Iterator && + typeof (state as DeprecatedMapState).Iterator === 'object' && + !Array.isArray((state as DeprecatedMapState).Iterator) + ) +} + +export function isParallelWithBranches(state: StateDefinition | undefined | null): state is ParallelWithBranches { + return !!state && state.Type === 'Parallel' && !!state.Branches && Array.isArray(state.Branches) +} + +export function isWithVariables(state: StateDefinition): state is WithVariables { + return ['Task', 'Map', 'Pass', 'Parallel', 'Choice', 'Wait'].includes(state.Type) +} + +export function isWithErrorHandled(state: StateDefinition): state is WithErrorHandled { + return ['Task', 'Map', 'Parallel'].includes(state.Type) +} diff --git a/src/asl-utils/asl/tests/branch.test.ts b/src/asl-utils/asl/tests/branch.test.ts new file mode 100644 index 000000000..546a9b53c --- /dev/null +++ b/src/asl-utils/asl/tests/branch.test.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { getBranchIndex, getStateIdFromBranchPath } from '../asl' + +describe('getStateIdFromBranchPath', () => { + it('should return stateId from a Parallel branch path', () => { + expect(getStateIdFromBranchPath(['ParallelState', 2])).toBe('ParallelState') + }) + + it('should return stateId from a branch path', () => { + expect(getStateIdFromBranchPath(['StateName'])).toBe('StateName') + }) +}) + +describe('getBranchIndex', () => { + it('should return the branch index from the branch name', () => { + expect(getBranchIndex('Branches[0]')).toEqual(0) + expect(getBranchIndex('Branches[10]')).toEqual(10) + }) + + it('should return null when it is not valid branch name', () => { + expect(getBranchIndex('Branches')).toBeNull() + expect(getBranchIndex('Branches[]')).toBeNull() + expect(getBranchIndex('Branch[0]')).toBeNull() + expect(getBranchIndex('branches[0]')).toBeNull() + expect(getBranchIndex('')).toBeNull() + expect(getBranchIndex('Branches[-1]')).toBeNull() + expect(getBranchIndex('Branches[1.1]')).toBeNull() + expect(getBranchIndex('Branches(0)')).toBeNull() + }) +}) diff --git a/src/asl-utils/asl/tests/definitions.test.ts b/src/asl-utils/asl/tests/definitions.test.ts new file mode 100644 index 000000000..771cf4499 --- /dev/null +++ b/src/asl-utils/asl/tests/definitions.test.ts @@ -0,0 +1,331 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { + DistributedMapProcessingMode, + getItemSelectorDefinition, + getItemSelectorFieldName, + getProcessorDefinition, + getProcessorFieldName, + isAslWithStates, + isChoice, + isChoiceWithChoices, + isDistributedMap, + isErrorHandled, + isFail, + isMap, + isMapWithIterator, + isNonTerminal, + isParallel, + isParallelWithBranches, + isWithErrorHandled, + isWithVariables, + isPass, + isPlaceholder, + isSucceed, + isTask, + isTerminal, + isWait, + isWithParameters, +} from '../definitions' + +describe('ASL definition type guards', () => { + it('checks state types correctly', () => { + // TODO: Separate each function into its own 'it' block + expect(isPass({ Type: 'Pass' })).toBeTruthy() + expect(isWait({ Type: 'Wait' })).toBeTruthy() + expect(isFail({ Type: 'Fail' })).toBeTruthy() + expect(isSucceed({ Type: 'Succeed' })).toBeTruthy() + expect(isTask({ Type: 'Task' })).toBeTruthy() + expect(isChoice({ Type: 'Choice' })).toBeTruthy() + expect(isMap({ Type: 'Map' })).toBeTruthy() + expect(isParallel({ Type: 'Parallel' })).toBeTruthy() + expect(isPlaceholder({ Type: 'Placeholder', PlaceholderLabel: 'x' })).toBeTruthy() + + expect(isPass({ Type: 'Map' })).toBeFalsy() + expect(isWait({ Type: 'Map' })).toBeFalsy() + expect(isFail({ Type: 'Map' })).toBeFalsy() + expect(isSucceed({ Type: 'Map' })).toBeFalsy() + expect(isTask({ Type: 'Map' })).toBeFalsy() + expect(isChoice({ Type: 'Map' })).toBeFalsy() + expect(isMap({ Type: 'Parallel' })).toBeFalsy() + expect(isParallel({ Type: 'Map' })).toBeFalsy() + expect(isPlaceholder({ Type: 'Map' })).toBeFalsy() + + expect(isTerminal({ Type: 'Fail' })).toBeTruthy() + expect(isTerminal({ Type: 'Succeed' })).toBeTruthy() + expect(isTerminal({ Type: 'Pass' })).toBeFalsy() + expect(isNonTerminal({ Type: 'Pass' })).toBeTruthy() + + expect(isErrorHandled({ Type: 'Map' })).toBeTruthy() + expect(isErrorHandled({ Type: 'Task' })).toBeTruthy() + expect(isErrorHandled({ Type: 'Parallel' })).toBeTruthy() + expect(isErrorHandled({ Type: 'Pass' })).toBeFalsy() + + expect(isWithParameters({ Type: 'Map' })).toBeTruthy() + expect(isWithParameters({ Type: 'Task' })).toBeTruthy() + expect(isWithParameters({ Type: 'Parallel' })).toBeTruthy() + expect(isWithParameters({ Type: 'Pass' })).toBeTruthy() + expect(isWithParameters({ Type: 'Succeed' })).toBeFalsy() + + expect(isAslWithStates(null)).toBeFalsy() + expect(isAslWithStates(undefined)).toBeFalsy() + expect(isAslWithStates({})).toBeFalsy() + expect(isAslWithStates({ States: {} })).toBeTruthy() + + expect(isChoiceWithChoices(null)).toBeFalsy() + expect(isChoiceWithChoices(undefined)).toBeFalsy() + expect(isChoiceWithChoices({ Type: 'Choice' })).toBeFalsy() + expect(isChoiceWithChoices({ Type: 'Pass' })).toBeFalsy() + expect(isChoiceWithChoices({ Type: 'Choice', Choices: [] })).toBeTruthy() + + expect(isMapWithIterator(null)).toBeFalsy() + expect(isMapWithIterator(undefined)).toBeFalsy() + expect(isMapWithIterator({ Type: 'Map' })).toBeFalsy() + expect(isMapWithIterator({ Type: 'Pass' })).toBeFalsy() + expect(isMapWithIterator({ Type: 'Map', Iterator: {} })).toBeTruthy() + + expect(isParallelWithBranches(null)).toBeFalsy() + expect(isParallelWithBranches(undefined)).toBeFalsy() + expect(isParallelWithBranches({ Type: 'Parallel' })).toBeFalsy() + expect(isParallelWithBranches({ Type: 'Pass' })).toBeFalsy() + expect(isParallelWithBranches({ Type: 'Parallel', Branches: [] })).toBeTruthy() + + expect(isWithVariables({ Type: 'Map' })).toBeTruthy() + expect(isWithVariables({ Type: 'Task' })).toBeTruthy() + expect(isWithVariables({ Type: 'Choice' })).toBeTruthy() + expect(isWithVariables({ Type: 'Parallel' })).toBeTruthy() + expect(isWithVariables({ Type: 'Pass' })).toBeTruthy() + expect(isWithVariables({ Type: 'Wait' })).toBeTruthy() + expect(isWithVariables({ Type: 'Succeed' })).toBeFalsy() + expect(isWithVariables({ Type: 'Fail' })).toBeFalsy() + + expect(isWithErrorHandled({ Type: 'Map' })).toBeTruthy() + expect(isWithErrorHandled({ Type: 'Task' })).toBeTruthy() + expect(isWithErrorHandled({ Type: 'Choice' })).toBeFalsy() + expect(isWithErrorHandled({ Type: 'Map' })).toBeTruthy() + expect(isWithErrorHandled({ Type: 'Pass' })).toBeFalsy() + }) + it('getProcessorDefinition', () => { + expect( + getProcessorDefinition({ + Type: 'Map', + ItemProcessor: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toStrictEqual({ + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }) + expect( + getProcessorDefinition({ + Type: 'Map', + Iterator: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toStrictEqual({ + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }) + }) + + it('getItemSelectorDefinition', () => { + expect( + getItemSelectorDefinition({ + Type: 'Map', + ItemProcessor: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + ItemSelector: { + a: 'a', + }, + }), + ).toStrictEqual({ + a: 'a', + }) + }) + + expect( + getItemSelectorDefinition({ + Type: 'Map', + Iterator: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + Parameters: { + a: 'a', + }, + }), + ).toStrictEqual({ + a: 'a', + }) + + it('getItemSelectorFieldName', () => { + expect( + getItemSelectorFieldName({ + Type: 'Map', + ItemProcessor: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + ItemSelector: { + a: 'a', + }, + }), + ).toStrictEqual('ItemSelector') + }) + + expect( + getItemSelectorFieldName({ + Type: 'Map', + Iterator: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + Parameters: { + a: 'a', + }, + }), + ).toStrictEqual('Parameters') + + it('isDistributedMap', () => { + expect( + isDistributedMap({ + Type: 'Map', + Iterator: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toBe(true) + expect( + isDistributedMap({ + Type: 'Map', + Iterator: { + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toBe(true) + }) + it('getProcessorFieldName', () => { + expect( + getProcessorFieldName({ + Type: 'Map', + Iterator: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toBe('Iterator') + expect( + getProcessorFieldName({ + Type: 'Map', + ItemProcessor: { + ProcessorConfig: { + Mode: DistributedMapProcessingMode.Distributed, + }, + StartAt: 'parallel-b-3-1', + States: { + 'parallel-b-3-1': { + Type: 'Pass', + End: true, + }, + }, + }, + }), + ).toBe('ItemProcessor') + }) +}) diff --git a/src/asl-utils/asl/tests/getAllChildren.test.ts b/src/asl-utils/asl/tests/getAllChildren.test.ts new file mode 100644 index 000000000..8f4f2c3ad --- /dev/null +++ b/src/asl-utils/asl/tests/getAllChildren.test.ts @@ -0,0 +1,178 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { Asl } from '../definitions' +import { getAllChildren } from '../asl' + +describe('getAllChildren', () => { + it('returns empty when state not found', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + End: true, + }, + }, + } + + const children = getAllChildren(asl, 'b') + + expect(children).toEqual([]) + }) + + it('returns children', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Map', + Iterator: { + StartAt: 'a-1', + States: { + 'a-1': { + Type: 'Pass', + Next: 'a-2', + }, + 'a-2': { + Type: 'Parallel', + Branches: [ + { + StartAt: 'a-2-1', + States: { + 'a-2-1': { + Type: 'Pass', + End: true, + }, + }, + }, + { + StartAt: 'a-2-2', + States: { + 'a-2-2': { + Type: 'Pass', + End: true, + }, + }, + }, + ], + End: true, + }, + }, + }, + }, + }, + } + + const children = getAllChildren(asl, 'a') + + expect(children).toStrictEqual(['a-1', 'a-2', 'a-2-1', 'a-2-2']) + }) + + it('returns all children of a distributed Map', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Map', + ItemProcessor: { + StartAt: 'a-1', + States: { + 'a-1': { + Type: 'Pass', + Next: 'a-2', + }, + 'a-2': { + Type: 'Parallel', + Branches: [ + { + StartAt: 'a-2-1', + States: { + 'a-2-1': { + Type: 'Pass', + End: true, + }, + }, + }, + { + StartAt: 'a-2-2', + States: { + 'a-2-2': { + Type: 'Pass', + End: true, + }, + }, + }, + ], + End: true, + }, + }, + }, + }, + }, + } + + const children = getAllChildren(asl, 'a') + + expect(children).toStrictEqual(['a-1', 'a-2', 'a-2-1', 'a-2-2']) + }) + + it('returns all children of a nested distributed Map', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Map', + ItemProcessor: { + StartAt: 'a-1', + States: { + 'a-1': { + Type: 'Pass', + Next: 'a-2', + }, + 'a-2': { + Type: 'Map', + ItemProcessor: { + StartAt: 'a-2-1', + States: { + 'a-2-1': { + Type: 'Parallel', + Branches: [ + { + StartAt: 'a-2-1-1', + States: { + 'a-2-1-1': { + Type: 'Pass', + End: true, + }, + }, + }, + { + StartAt: 'a-2-1-2', + States: { + 'a-2-1-2': { + Type: 'Pass', + End: true, + }, + }, + }, + ], + End: true, + }, + }, + }, + End: true, + }, + }, + }, + }, + }, + } + + const children = getAllChildren(asl, 'a') + + expect(children).toStrictEqual(['a-1', 'a-2', 'a-2-1', 'a-2-1-1', 'a-2-1-2']) + }) +}) diff --git a/src/asl-utils/asl/tests/next.test.ts b/src/asl-utils/asl/tests/next.test.ts new file mode 100644 index 000000000..893d2eab9 --- /dev/null +++ b/src/asl-utils/asl/tests/next.test.ts @@ -0,0 +1,36 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { getDirectNext } from '../asl' + +describe('getDirectNext', () => { + it('should return null for terminal states', () => { + expect(getDirectNext({ Type: 'Succeed' })).toBeNull() + }) + + it('should return Next for normal states', () => { + expect(getDirectNext({ Type: 'Pass', Next: 'a' })).toBe('a') + }) + + it('should return null for normal states when there is no Next', () => { + expect(getDirectNext({ Type: 'Pass' })).toBeNull() + }) + + it('should return Default for Choice', () => { + expect(getDirectNext({ Type: 'Choice', Default: 'a' })).toBe('a') + }) + + it('should return First choice Next when no default', () => { + expect(getDirectNext({ Type: 'Choice', Choices: [{ Next: 'a' }] })).toBe('a') + }) + + it('should return null when there is no Next in choice rule', () => { + expect(getDirectNext({ Type: 'Choice', Choices: [{}] })).toBeNull() + }) + + it('should return null when there is no Choice rule nor Default', () => { + expect(getDirectNext({ Type: 'Choice' })).toBeNull() + }) +}) diff --git a/src/asl-utils/asl/tests/visit.test.ts b/src/asl-utils/asl/tests/visit.test.ts new file mode 100644 index 000000000..92b5e14f6 --- /dev/null +++ b/src/asl-utils/asl/tests/visit.test.ts @@ -0,0 +1,392 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { Asl } from '../definitions' +import { StatePath, visitAllStates, visitAllStatesInBranch, getAllStateIds } from '../asl' + +describe('visitAllStates', () => { + it('visits all states', () => { + const asl: Asl = { + StartAt: 'parallel', + States: { + parallel: { + Type: 'Parallel', + Branches: [ + { + StartAt: 'parallel-0-a', + States: { + 'parallel-0-a': { + Type: 'Pass', + Next: 'parallel-0-b', + }, + 'parallel-0-b': { + Type: 'Map', + Iterator: { + StartAt: 'parallel-0-b-x', + States: { + 'parallel-0-b-x': { + Type: 'Pass', + Next: 'parallel-0-b-y', + }, + 'parallel-0-b-y': { + Type: 'Pass', + End: true, + }, + }, + }, + Next: 'parallel-0-c', + }, + 'parallel-0-c': { + Type: 'Parallel', + End: true, + }, + }, + }, + { + StartAt: 'parallel-1-a', + States: { + 'parallel-1-a': { + Type: 'Pass', + End: true, + }, + }, + }, + ], + Next: 'pass', + }, + pass: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateInfo: { id: string; path: StatePath }[] = [] + + visitAllStates(asl, (id, _, __, path) => { + visitedStateInfo.push({ id, path }) + return true + }) + + expect(visitedStateInfo).toEqual([ + { id: 'parallel', path: ['parallel'] }, + { id: 'parallel-0-a', path: ['parallel', 0, 'parallel-0-a'] }, + { id: 'parallel-0-b', path: ['parallel', 0, 'parallel-0-b'] }, + { id: 'parallel-0-b-x', path: ['parallel', 0, 'parallel-0-b', 'parallel-0-b-x'] }, + { id: 'parallel-0-b-y', path: ['parallel', 0, 'parallel-0-b', 'parallel-0-b-y'] }, + { id: 'parallel-0-c', path: ['parallel', 0, 'parallel-0-c'] }, + { id: 'parallel-1-a', path: ['parallel', 1, 'parallel-1-a'] }, + { id: 'pass', path: ['pass'] }, + ]) + }) + + it('visits orphan states', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + End: true, + }, + b: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return true + }) + + expect(visitedStateIds).toEqual(['a', 'b']) + }) + + it('does not loop', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + Next: 'b', + }, + b: { + Type: 'Pass', + Next: 'a', + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return true + }) + + expect(visitedStateIds).toEqual(['a', 'b']) + }) + + it('stops when visitor returns false', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + Next: 'b', + }, + b: { + Type: 'Pass', + Next: 'c', + }, + c: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return id !== 'b' + }) + + expect(visitedStateIds).toEqual(['a', 'b']) + }) + + it('stops when visitor returns false in legacy Map', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + Next: 'b', + }, + map: { + Type: 'Map', + Iterator: { + StartAt: 'x', + States: { + x: { + Type: 'Pass', + Next: 'y', + }, + y: { + Type: 'Pass', + End: true, + }, + }, + }, + Next: 'c', + }, + c: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return id !== 'x' + }) + + expect(visitedStateIds).toEqual(['a', 'map', 'x']) + }) + + it('stops when visitor returns false in Distributed Map', () => { + const asl: Asl = { + StartAt: 'Map', + States: { + map: { + Type: 'Map', + ItemProcessor: { + StartAt: 'x', + States: { + x: { + Type: 'Pass', + Next: 'y', + }, + y: { + Type: 'Pass', + End: true, + }, + }, + }, + Next: 'c', + }, + c: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateIds: string[] = [] + const stopped = !visitAllStatesInBranch(asl, ['map'], (id) => { + visitedStateIds.push(id) + return id !== 'x' + }) + expect(stopped).toBe(true) + + const notStopped = visitAllStatesInBranch(asl, ['map'], (id) => { + visitedStateIds.push(id) + return true + }) + expect(notStopped).toBe(true) + }) + + it('stops when visitor returns false in Parallel', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + Next: 'b', + }, + parallel: { + Type: 'Parallel', + Branches: [ + { + StartAt: 'x1', + States: { + x1: { + Type: 'Pass', + Next: 'y1', + }, + y1: { + Type: 'Pass', + End: true, + }, + }, + }, + { + StartAt: 'x2', + States: { + x2: { + Type: 'Pass', + Next: 'y2', + }, + y2: { + Type: 'Pass', + End: true, + }, + }, + }, + ], + Next: 'c', + }, + c: { + Type: 'Pass', + End: true, + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return id !== 'x1' + }) + + expect(visitedStateIds).toEqual(['a', 'parallel', 'x1']) + }) + + it('should not visit if there is no States', () => { + const asl: Asl = { + StartAt: 'a', + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return true + }) + + expect(visitedStateIds).toEqual([]) + }) + + it('should handle Iterator without States', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Map', + Iterator: {}, + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return true + }) + + expect(visitedStateIds).toEqual(['a']) + }) + + it('should handle branch without States', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Parallel', + Branches: [{}], + }, + }, + } + + const visitedStateIds: string[] = [] + visitAllStates(asl, (id) => { + visitedStateIds.push(id) + return true + }) + + expect(visitedStateIds).toEqual(['a']) + }) +}) + +describe('getAllStateIds', () => { + it('should get all stateIds', () => { + const asl: Asl = { + StartAt: 'a', + States: { + a: { + Type: 'Pass', + Next: 'b', + }, + map: { + Type: 'Map', + Iterator: { + StartAt: 'x', + States: { + x: { + Type: 'Pass', + Next: 'y', + }, + y: { + Type: 'Pass', + End: true, + }, + }, + }, + Next: 'c', + }, + c: { + Type: 'Pass', + End: true, + }, + }, + } + + const statesIds = getAllStateIds(asl) + + expect(statesIds).toEqual(['a', 'map', 'x', 'y', 'c']) + }) +}) diff --git a/src/asl-utils/index.ts b/src/asl-utils/index.ts new file mode 100644 index 000000000..5f1df992f --- /dev/null +++ b/src/asl-utils/index.ts @@ -0,0 +1,10 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +export * from './asl/asl' +export * from './asl/definitions' + +export * from './utils/autocomplete' +export * from './utils/jsonata' diff --git a/src/asl-utils/utils/autocomplete.ts b/src/asl-utils/utils/autocomplete.ts new file mode 100644 index 000000000..b686801aa --- /dev/null +++ b/src/asl-utils/utils/autocomplete.ts @@ -0,0 +1,374 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ +import merge from 'lodash/merge' +import get from 'lodash/get' +import { + Asl, + WithVariables, + StateId, + isDistributedMap, + isDistributedMode, + isChoice, + JsonObject, + JsonMap, + isWithVariables, + isWithErrorHandled, + StateType, +} from '../asl/definitions' +import { getStateIdFromBranchPath, visitAllStates, getDirectNext, findStateById } from '../asl/asl' + +import { deepClone, isJSONataExpression, isValidJSON } from './utils' + +export const JSON_EDITING_PROPERTY = 'ValueEnteredInForm' +export const VARIABLE_PREFIX = '$' +export const CONTEXT_OBJECT_PREFIX = '$$.' +export const VARIABLE_TRANSFORM_KEY_SUFFIX = '.$' + +export const CONTEXT_OBJECT_KEYS = { + Execution: { + Id: null, + Input: null, + Name: null, + RoleArn: null, + StartTime: null, + RedriveCount: null, + RedriveTime: null, + }, + State: { + EnteredTime: null, + Name: null, + RetryCount: null, + }, + StateMachine: { + Id: null, + Name: null, + }, + Task: { + Token: null, + }, +} + +const MAP_STATE_CONTEXT = { + Map: { + Item: { + Index: null, + Value: null, + }, + }, +} + +export const RESERVED_VARIABLES = { + states: { + input: null, // the state's raw input, + context: CONTEXT_OBJECT_KEYS, // the Context Object + }, +} + +export const RESERVED_VARIABLES_ERROR = { + states: { + errorOutput: null, // the Error Output (in a Catch), + }, +} + +export const RESERVED_VARIABLES_SUCCESS = { + states: { + result: null, // the API or sub-workflow's result (if successful), + }, +} + +export type PreviousStatesMap = Record> +export interface VariableCompletionList { + localScope: JsonMap + outerScope?: JsonMap +} + +interface StateToExplore { + stateId: StateId + nextState: StateId +} + +let previousStatesMap: PreviousStatesMap = {} + +/** + * Find previous nodes for each state in asl. + * @param asl + * @returns hash map with stateId mapping to a set of previous nodes + */ +export const buildPreviousStatesMap = (asl: Asl): Record> => { + previousStatesMap = {} + visitAllStates(asl, (id, state) => { + const nextStateIds = [getDirectNext(state)] + + if (isChoice(state)) { + state.Choices?.forEach((choice) => { + choice.Next && nextStateIds.push(choice.Next) + }) + state.Default && nextStateIds.push(state.Default) + } + + if (isWithErrorHandled(state)) { + state.Catch?.forEach((rule) => { + rule.Next && nextStateIds.push(rule.Next) + }) + } + + nextStateIds.forEach((nextStateId) => { + if (!nextStateId) return + if (previousStatesMap[nextStateId]) { + previousStatesMap[nextStateId].add(id) + } else { + previousStatesMap[nextStateId] = new Set([id]) + } + }) + return true + }) + return previousStatesMap +} + +/** + * Get Assign varaible JSON object to be passed to the next state + * The return JsonObject contains variables keys only + * @param state + * @param nextStateId + * @returns + */ +const getAssignVariables = (state: WithVariables, nextStateId: string): JsonObject => { + let variables: JsonObject = {} + + if (isWithErrorHandled(state)) { + state.Catch?.forEach((catcher) => { + if (catcher.Assign && catcher.Next === nextStateId) { + variables = merge(variables, getAssignKeys(catcher.Assign)) + } + }) + } + if (isChoice(state)) { + state.Choices?.forEach((choice) => { + if (choice.Assign && choice.Next === nextStateId) { + variables = merge(variables, getAssignKeys(choice.Assign)) + } + }) + if (state.Default === nextStateId && state.Assign) { + variables = merge(variables, getAssignKeys(state.Assign)) + } + } else if (state.Next === nextStateId && state.Assign) { + variables = merge(variables, getAssignKeys(state.Assign)) + } + + return variables +} + +/** + * Helper function to keep only object keys and discard value in JsonMap and JSONArray. + * @param jsonField input JsonObject + * @returns JsonObject with keys only, null if key value has a typeof JsonPrimitive + */ +const getJsonKeysFromArrayOrObject = (jsonField: JsonObject): JsonObject | null => { + const keys: JsonObject = {} + if (Array.isArray(jsonField)) { + return jsonField.map((element) => getJsonKeysFromArrayOrObject(element)) + } else if (jsonField && typeof jsonField === 'object') { + for (const jsonKey of Object.keys(jsonField)) { + keys[jsonKey] = getJsonKeysFromArrayOrObject(jsonField[jsonKey]) + } + } + return Object.keys(keys) ? keys : null +} + +/** + * Helper function to keep only keys and discard value in JsonMap. + * Return jsonMap has the same stcuture as the input JSON. + * @param json input json map + * @returns JsonMap with keys only + */ +const getAssignKeys = (json: JsonMap): JsonMap | null => { + const keysMap: JsonMap = {} + for (const key of Object.keys(json)) { + // skip WFS editing property + if (key === JSON_EDITING_PROPERTY) { + continue + } + keysMap[key] = getJsonKeysFromArrayOrObject(json[key]) + } + return Object.entries(keysMap).length ? keysMap : null +} + +/** + * Generate autocomplete Suggestion of a state, for both local scope and outer scope. + * The function traverses the graph in reverse, from Next to Previous nodes until the first state node is reached. + * @param asl state machine definition + * @param target state where the auto-completion list is generated for + * @param current state where the search is at, when entering this function. + * This could be the target state itself, or the map/parallel state in parent scope of target start. + * @returns VariableCompletionList + */ +export const getAssignCompletionList = (asl: Asl, target: StateId, current: StateId): VariableCompletionList => { + const completionList: VariableCompletionList = { + localScope: {}, + outerScope: {}, + } + const visited: Set = new Set([]) + let statesToExplore: StateToExplore[] = [] + + const { parentPath: currentParentPath, state: targetState } = findStateById(asl, current) + if (!targetState) return completionList + + const isCurrentDistributedMap = isDistributedMap(targetState) && isDistributedMode(targetState) + const parentStateId = currentParentPath && getStateIdFromBranchPath(currentParentPath) + const isCalledFromSubWorkflow = target !== current + + const parentStates = previousStatesMap[current] ? [...previousStatesMap[current]] : [] + + // only continue to explore parent nodes if the search is not from a sub-workflow inside distributed map + if (!isCalledFromSubWorkflow || !isCurrentDistributedMap) { + const previousStatesToExplore = parentStates.map((stateId) => ({ stateId, nextState: current })) + statesToExplore.push(...previousStatesToExplore) + } + + let stateToExplore: StateToExplore | undefined + while ((stateToExplore = statesToExplore.pop())) { + const stateName = stateToExplore.stateId + const nextState = stateToExplore.nextState + const stateVisitedToken = `${stateName}_${nextState}` + + if (visited.has(stateVisitedToken)) continue + + const { state } = findStateById(asl, stateName, currentParentPath || undefined) + + if (state && isWithVariables(state)) { + completionList.localScope = merge(completionList.localScope, getAssignVariables(state, nextState)) + visited.add(stateVisitedToken) + + if (previousStatesMap[stateName]) { + statesToExplore = statesToExplore.concat( + [...previousStatesMap[stateName]].map((parentNode) => ({ + stateId: parentNode, + nextState: stateName, + })), + ) + } + } + } + let parentVariables: VariableCompletionList | undefined = undefined + if (parentStateId) { + parentVariables = getAssignCompletionList(asl, current, parentStateId) + completionList.outerScope = merge(parentVariables?.localScope || {}, parentVariables?.outerScope || {}) + } + return completionList +} + +export interface GetReservedVariablesParams { + isError?: boolean + isSuccess?: boolean + isItemSelector?: boolean + stateType: StateType +} + +/** + * Generate reserved variables auto-complete suggestion based on state context + * @param param.isError if editor is for error catcher blocker + * @param param.isSuccess if editor is for assignment after state success + * @param param.isItemSelector if editor is for item selector in a map state + * @param param.stateType State type + * @returns object keys of reserved variables + */ +export const getReservedVariables = (params: GetReservedVariablesParams): Record => { + const { isError, isSuccess, isItemSelector, stateType } = params + let reservedVariables = deepClone(RESERVED_VARIABLES) + + if (isItemSelector) { + reservedVariables.states.context = merge(RESERVED_VARIABLES.states.context, MAP_STATE_CONTEXT) + } + + if (isError) { + reservedVariables = merge(reservedVariables, RESERVED_VARIABLES_ERROR) + } else if (isSuccess && (stateType === 'Map' || stateType === 'Task' || stateType === 'Parallel')) { + reservedVariables = merge(reservedVariables, RESERVED_VARIABLES_SUCCESS) + } + + return reservedVariables +} + +export interface GetMonacoCompletionsParams { + nodeVal: string + completionScope: VariableCompletionList + reservedVariablesParams: GetReservedVariablesParams +} + +export interface MonacoCompletionsResult { + parentPath: string + items: string[] +} + +export const getCompletionStrings = (params: GetMonacoCompletionsParams): MonacoCompletionsResult => { + const { nodeVal, completionScope } = params + const items: string[] = [] + + const objectPath = nodeVal.replace(VARIABLE_PREFIX, '').split('.') + const parentPathKey = objectPath.slice(0, objectPath.length - 1).join('.') + const variablePrefix = objectPath.length > 1 ? '' : VARIABLE_PREFIX + + const reservedVariables = getReservedVariables(params.reservedVariablesParams) + + const allVariableOptions = merge(completionScope.outerScope, completionScope.localScope, reservedVariables) + const autoCompleteScope = parentPathKey ? get(allVariableOptions, parentPathKey) : allVariableOptions + + Object.keys(autoCompleteScope || {}).forEach((variable: string) => { + const keyName = variable.replace(VARIABLE_TRANSFORM_KEY_SUFFIX, '') + items.push(`${variablePrefix}${keyName}`) + }) + return { + parentPath: parentPathKey, + items, + } +} + +const JSONATA_MACRO_REGEX = /^\s*{%\s*%?}?\s*/ +const JSONATA_TYPO_MACRO_REGEX = /^\s*%{?\s*}?%?\s*/ + +const trimMacroContent = (text: string, prefix: string, suffix: string): string => { + let textWithPrefixTrimmed = text.trim() + for (const character of prefix) { + if (textWithPrefixTrimmed.startsWith(character)) { + textWithPrefixTrimmed = textWithPrefixTrimmed.slice(1) + } + } + + let textWithSuffixTrimmed = textWithPrefixTrimmed + for (const character of suffix.split('').reverse().join('')) { + if (textWithSuffixTrimmed.endsWith(character)) { + textWithSuffixTrimmed = textWithSuffixTrimmed.slice(0, textWithSuffixTrimmed.length - 1) + } + } + + return textWithSuffixTrimmed +} + +export const getJSONataMacroContent = ( + text: string, +): { + content: string + isTypo: boolean +} | null => { + if (isJSONataExpression(text) || isValidJSON(text) || text.includes('\n')) { + return null + } + + if (JSONATA_MACRO_REGEX.test(text)) { + return { + content: trimMacroContent(text, '{%', '%}'), + isTypo: false, + } + } + + if (JSONATA_TYPO_MACRO_REGEX.test(text)) { + return { + content: trimMacroContent(text, '%{', '}%'), + isTypo: true, + } + } + + return null +} diff --git a/src/asl-utils/utils/jsonata/functions.ts b/src/asl-utils/utils/jsonata/functions.ts new file mode 100644 index 000000000..71ac7db7d --- /dev/null +++ b/src/asl-utils/utils/jsonata/functions.ts @@ -0,0 +1,844 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +// Function descriptions have a large bundle size, so only import this file dynamically. + +export type FunctionCategory = + | 'string' + | 'numeric' + | 'aggregation' + | 'boolean' + | 'array' + | 'object' + | 'date' + | 'higher-order' + +export interface FunctionParam { + name: string + optional?: boolean + variable?: boolean +} + +export interface FunctionType { + params: ReadonlyArray + category: FunctionCategory + description: string +} + +export type JsonataFunctionsMap = Map + +const jsonataFunctionsList: Record = { + $string: { + params: [ + { + name: 'arg', + }, + { + name: 'prettify', + }, + ], + category: 'string', + description: + 'Casts the `arg` parameter to a string using the following casting rules\n\n - Strings are unchanged\n - Functions are converted to an empty string\n - Numeric infinity and NaN throw an error because they cannot be represented as a JSON number\n\nIf `arg` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `arg`.\n\nIf `prettify` is true, then "prettified" JSON is produced. i.e One line per field and lines will be indented based on the field depth.\n\n__Examples__\n\n- `$string(5)` => `"5"`\n- `[1..5].$string()` => `["1", "2", "3", "4", "5"]`', + }, + $length: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Returns the number of characters in the string `str`. If `str` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `str`. An error is thrown if `str` is not a string.\n\n__Examples__\n\n- `$length("Hello World")` => `11`', + }, + $substring: { + params: [ + { + name: 'str', + }, + { + name: 'start', + }, + { + name: 'length', + optional: true, + }, + ], + category: 'string', + description: + 'Returns a string containing the characters in the first parameter `str` starting at position `start` (zero-offset). If `str` is not specified (i.e. this function is invoked with only the numeric argument(s)), then the context value is used as the value of `str`. An error is thrown if `str` is not a string.\n\nIf `length` is specified, then the substring will contain maximum `length` characters.\n\nIf `start` is negative then it indicates the number of characters from the end of `str`. \n\n__Examples__\n\n- `$substring("Hello World", 3)` => `"lo World"`\n- `$substring("Hello World", 3, 5)` => `"lo Wo"`\n- `$substring("Hello World", -4)` => `"orld"`\n- `$substring("Hello World", -4, 2)` => `"or"`', + }, + $substringBefore: { + params: [ + { + name: 'str', + }, + { + name: 'chars', + }, + ], + category: 'string', + description: + 'Returns the substring before the first occurrence of the character sequence `chars` in `str`. If `str` is not specified (i.e. this function is invoked with only one argument), then the context value is used as the value of `str`. If `str` does not contain `chars`, then it returns `str`. An error is thrown if `str` and `chars` are not strings.\n\n__Examples__\n\n- `$substringBefore("Hello World", " ")` => `"Hello"`', + }, + $substringAfter: { + params: [ + { + name: 'str', + }, + { + name: 'chars', + }, + ], + category: 'string', + description: + 'Returns the substring after the first occurrence of the character sequence `chars` in `str`. If `str` is not specified (i.e. this function is invoked with only one argument), then the context value is used as the value of `str`. If `str` does not contain `chars`, then it returns `str`. An error is thrown if `str` and `chars` are not strings.\n\n__Examples__\n\n- `$substringAfter("Hello World", " ")` => `"World"`', + }, + $uppercase: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Returns a string with all the characters of `str` converted to uppercase. If `str` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `str`. An error is thrown if `str` is not a string.\n\n__Examples__\n\n- `$uppercase("Hello World")` => `"HELLO WORLD"`', + }, + $lowercase: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Returns a string with all the characters of `str` converted to lowercase. If `str` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `str`. An error is thrown if `str` is not a string.\n\n__Examples__\n\n- `$lowercase("Hello World")` => `"hello world"`', + }, + $trim: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Normalizes and trims all whitespace characters in `str` by applying the following steps:\n\n- All tabs, carriage returns, and line feeds are replaced with spaces.\n- Contiguous sequences of spaces are reduced to a single space.\n- Trailing and leading spaces are removed.\n\nIf `str` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `str`. An error is thrown if `str` is not a string.\n\n__Examples__\n\n- `$trim(" Hello \\n World ")` => `"Hello World"`', + }, + $pad: { + params: [ + { + name: 'str', + }, + { + name: 'width', + }, + { + name: 'char', + optional: true, + }, + ], + category: 'string', + description: + 'Returns a copy of the string `str` with extra padding, if necessary, so that its total number of characters is at least the absolute value of the `width` parameter. If `width` is a positive number, then the string is padded to the right; if negative, it is padded to the left. The optional `char` argument specifies the padding character(s) to use. If not specified, it defaults to the space character.\n\n__Examples__\n\n- `$pad("foo", 5)` => `"foo "`\n- `$pad("foo", -5)` => `" foo"`\n- `$pad("foo", -5, "#")` => `"##foo"`\n- `$formatBase(35, 2) ~> $pad(-8, \'0\')` => `"00100011"`', + }, + $contains: { + params: [ + { + name: 'str', + }, + { + name: 'pattern', + }, + ], + category: 'string', + description: + 'Returns `true` if `str` is matched by `pattern`, otherwise it returns `false`. If `str` is not specified (i.e. this function is invoked with one argument), then the context value is used as the value of `str`.\n\nThe `pattern` parameter can either be a string or a regular expression (regex). If it is a string, the function returns `true` if the characters within `pattern` are contained contiguously within `str`. If it is a regex, the function will return `true` if the regex matches the contents of `str`.\n\n__Examples__\n\n- `$contains("abracadabra", "bra")` => `true`\n- `$contains("abracadabra", /a.*a/)` => `true`\n- `$contains("abracadabra", /ar.*a/)` => `false`\n- `$contains("Hello World", /wo/)` => `false`\n- `$contains("Hello World", /wo/i)` => `true`\n- `Phone[$contains(number, /^077/)]` => `{ "type": "mobile", "number": "077 7700 1234" }`', + }, + $split: { + params: [ + { + name: 'str', + }, + { + name: 'separator', + }, + { + name: 'limit', + optional: true, + }, + ], + category: 'string', + description: + 'Splits the `str` parameter into an array of substrings. If `str` is not specified, then the context value is used as the value of `str`. It is an error if `str` is not a string.\n\nThe `separator` parameter can either be a string or a regular expression (regex). If it is a string, it specifies the characters within `str` about which it should be split. If it is the empty string, `str` will be split into an array of single characters. If it is a regex, it splits the string around any sequence of characters that match the regex.\n\nThe optional `limit` parameter is a number that specifies the maximum number of substrings to include in the resultant array. Any additional substrings are discarded. If `limit` is not specified, then `str` is fully split with no limit to the size of the resultant array. It is an error if `limit` is not a non-negative number.\n\n__Examples__\n\n- `$split("so many words", " ")` => `[ "so", "many", "words" ]`\n- `$split("so many words", " ", 2)` => `[ "so", "many" ]`\n- `$split("too much, punctuation. hard; to read", /[ ,.;]+/)` => `["too", "much", "punctuation", "hard", "to", "read"]`', + }, + $join: { + params: [ + { + name: 'array', + }, + { + name: 'separator', + optional: true, + }, + ], + category: 'string', + description: + "Joins an array of component strings into a single concatenated string with each component string separated by the optional `separator` parameter.\n\nIt is an error if the input array contains an item which isn't a string.\n\nIf `separator` is not specified, then it is assumed to be the empty string, i.e. no separator between the component strings. It is an error if `separator` is not a string.\n\n__Examples__\n\n- `$join(['a','b','c'])` => `\"abc\"`\n- `$split(\"too much, punctuation. hard; to read\", /[ ,.;]+/, 3) ~> $join(', ')` => `\"too, much, punctuation\"`", + }, + $match: { + params: [ + { + name: 'str', + }, + { + name: 'pattern', + }, + { + name: 'limit', + optional: true, + }, + ], + category: 'string', + description: + 'Applies the `str` string to the `pattern` regular expression and returns an array of objects, with each object containing information about each occurrence of a match withing `str`.\n\nThe object contains the following fields:\n\n- `match` - the substring that was matched by the regex.\n- `index` - the offset (starting at zero) within `str` of this match.\n- `groups` - if the regex contains capturing groups (parentheses), this contains an array of strings representing each captured group.\n\nIf `str` is not specified, then the context value is used as the value of `str`. It is an error if `str` is not a string.\n\n__Examples__\n\n`$match("ababbabbcc",/a(b+)/)` =>\n```\n[\n {\n "match": "ab",\n "index": 0,\n "groups": ["b"]\n },\n {\n "match": "abb",\n "index": 2,\n "groups": ["bb"]\n },\n {\n "match": "abb",\n "index": 5,\n "groups": ["bb" ]\n }\n]\n```', + }, + $replace: { + params: [ + { + name: 'str', + }, + { + name: 'pattern', + }, + { + name: 'replacement', + }, + { + name: 'limit', + optional: true, + }, + ], + category: 'string', + description: + 'Finds occurrences of `pattern` within `str` and replaces them with `replacement`.\n\nIf `str` is not specified, then the context value is used as the value of `str`. It is an error if `str` is not a string.\n\nThe `pattern` parameter can either be a string or a regular expression (regex). If it is a string, it specifies the substring(s) within `str` which should be replaced. If it is a regex, its is used to find .\n\nThe `replacement` parameter can either be a string or a function. If it is a string, it specifies the sequence of characters that replace the substring(s) that are matched by `pattern`. If `pattern` is a regex, then the `replacement` string can refer to the characters that were matched by the regex as well as any of the captured groups using a `$` followed by a number `N`:\n\n- If `N = 0`, then it is replaced by substring matched by the regex as a whole.\n- If `N > 0`, then it is replaced by the substring captured by the Nth parenthesised group in the regex.\n- If `N` is greater than the number of captured groups, then it is replaced by the empty string.\n- A literal `$` character must be written as `$$` in the `replacement` string\n\nIf the `replacement` parameter is a function, then it is invoked for each match occurrence of the `pattern` regex. The `replacement` function must take a single parameter which will be the object structure of a regex match as described in the `$match` function; and must return a string.\n\nThe optional `limit` parameter, is a number that specifies the maximum number of replacements to make before stopping. The remainder of the input beyond this limit will be copied to the output unchanged.\n\n__Examples__\n\n
\n
$replace("John Smith and John Jones", "John", "Mr")
\n
"Mr Smith and Mr Jones"
\n
\n\n
\n
$replace("John Smith and John Jones", "John", "Mr", 1)
\n
"Mr Smith and John Jones"
\n
\n\n
\n
$replace("abracadabra", /a.*?a/, "*")
\n
"*c*bra"
\n
\n\n
\n
$replace("John Smith", /(\\w+)\\s(\\w+)/, "$2, $1")
\n
"Smith, John"
\n
\n\n
\n
$replace("265USD", /([0-9]+)USD/, "$$$1")
\n
"$265"
\n
\n\n
\n
(\n $convert := function($m) {\n ($number($m.groups[0]) - 32) * 5/9 & "C"\n };\n $replace("temperature = 68F today", /(\\d+)F/, $convert)\n)
\n
"temperature = 20C today"
\n
', + }, + $base64encode: { + params: [], + category: 'string', + description: + 'Converts an ASCII string to a base 64 representation. Each each character in the string is treated as a byte of binary data. This requires that all characters in the string are in the 0x00 to 0xFF range, which includes all characters in URI encoded strings. Unicode characters outside of that range are not supported.\n\n__Examples__\n\n- `$base64encode("abc:def")` => `"YWJjOmRlZg=="`', + }, + $base64decode: { + params: [], + category: 'string', + description: + 'Converts base 64 encoded bytes to a string, using a UTF-8 Unicode codepage.\n\n__Examples__\n\n- `$base64decode("YWJjOmRlZg==")` => `"abc:def"`', + }, + $encodeUrlComponent: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Encodes a Uniform Resource Locator (URL) component by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.\n\n__Examples__\n\n- `$encodeUrlComponent("?x=test")` => `"%3Fx%3Dtest"`', + }, + $encodeUrl: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Encodes a Uniform Resource Locator (URL) by replacing each instance of certain characters by one, two, three, or four escape sequences representing the UTF-8 encoding of the character.\n\n__Examples__\n\n- `$encodeUrl("https://mozilla.org/?x=шеллы")` => `"https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B"`', + }, + $decodeUrlComponent: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Decodes a Uniform Resource Locator (URL) component previously created by encodeUrlComponent.\n\n__Examples__\n\n- `$decodeUrlComponent("%3Fx%3Dtest")` => `"?x=test"`', + }, + $decodeUrl: { + params: [ + { + name: 'str', + }, + ], + category: 'string', + description: + 'Decodes a Uniform Resource Locator (URL) previously created by encodeUrl.\n\n__Examples__\n\n- `$decodeUrl("https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B")` => `"https://mozilla.org/?x=шеллы"`', + }, + $number: { + params: [ + { + name: 'arg', + }, + ], + category: 'numeric', + description: + 'Casts the `arg` parameter to a number using the following casting rules\n - Numbers are unchanged\n - Strings that contain a sequence of characters that represent a legal JSON number are converted to that number\n - Hexadecimal numbers start with `0x`, Octal numbers with `0o`, binary numbers with `0b`\n - Boolean `true` casts to `1`, Boolean `false` casts to `0`\n - All other values cause an error to be thrown.\n\nIf `arg` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `arg`. \n\n__Examples__ \n- `$number("5")` => `5` \n- `$number("0x12")` => `0x18` \n- `["1", "2", "3", "4", "5"].$number()` => `[1, 2, 3, 4, 5]`', + }, + $abs: { + params: [ + { + name: 'arg', + }, + ], + category: 'numeric', + description: + 'Returns the absolute value of the `number` parameter, i.e. if the number is negative, it returns the positive value.\n\nIf `number` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `number`. \n\n__Examples__ \n- `$abs(5)` => `5` \n- `$abs(-5)` => `5`', + }, + $floor: { + params: [ + { + name: 'number', + }, + ], + category: 'numeric', + description: + 'Returns the value of `number` rounded down to the nearest integer that is smaller or equal to `number`. \n\nIf `number` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `number`. \n\n__Examples__ \n- `$floor(5)` => `5` \n- `$floor(5.3)` => `5` \n- `$floor(5.8)` => `5` \n- `$floor(-5.3)` => `-6`', + }, + $ceil: { + params: [ + { + name: 'number', + }, + ], + category: 'numeric', + description: + 'Returns the value of `number` rounded up to the nearest integer that is greater than or equal to `number`. \n\nIf `number` is not specified (i.e. this function is invoked with no arguments), then the context value is used as the value of `number`. \n\n__Examples__ \n- `$ceil(5)` => `5` \n- `$ceil(5.3)` => `6` \n- `$ceil(5.8)` => `6` \n- `$ceil(-5.3)` => `-5`', + }, + $round: { + params: [ + { + name: 'number', + }, + { + name: 'precision', + optional: true, + }, + ], + category: 'numeric', + description: + 'Returns the value of the `number` parameter rounded to the number of decimal places specified by the optional `precision` parameter. \n\nThe `precision` parameter (which must be an integer) species the number of decimal places to be present in the rounded number. If `precision` is not specified then it defaults to the value `0` and the number is rounded to the nearest integer. If `precision` is negative, then its value specifies which column to round to on the left side of the decimal place\n\nThis function uses the [Round half to even](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) strategy to decide which way to round numbers that fall exactly between two candidates at the specified precision. This strategy is commonly used in financial calculations and is the default rounding mode in IEEE 754.\n\n__Examples__ \n- `$round(123.456)` => `123` \n- `$round(123.456, 2)` => `123.46` \n- `$round(123.456, -1)` => `120` \n- `$round(123.456, -2)` => `100` \n- `$round(11.5)` => `12` \n- `$round(12.5)` => `12` \n- `$round(125, -1)` => `120`', + }, + $power: { + params: [ + { + name: 'base', + }, + { + name: 'exponent', + }, + ], + category: 'numeric', + description: + 'Returns the value of `base` raised to the power of `exponent` (baseexponent).\n\nIf `base` is not specified (i.e. this function is invoked with one argument), then the context value is used as the value of `base`. \n\nAn error is thrown if the values of `base` and `exponent` lead to a value that cannot be represented as a JSON number (e.g. Infinity, complex numbers).\n\n__Examples__ \n- `$power(2, 8)` => `256` \n- `$power(2, 0.5)` => `1.414213562373` \n- `$power(2, -2)` => `0.25`', + }, + $sqrt: { + params: [ + { + name: 'number', + }, + ], + category: 'numeric', + description: + 'Returns the square root of the value of the `number` parameter.\n\nIf `number` is not specified (i.e. this function is invoked with one argument), then the context value is used as the value of `number`. \n\nAn error is thrown if the value of `number` is negative.\n\n__Examples__ \n- `$sqrt(4)` => `2`\n- `$sqrt(2)` => `1.414213562373`', + }, + $formatNumber: { + params: [ + { + name: 'number', + }, + { + name: 'picture', + }, + { + name: 'options', + optional: true, + }, + ], + category: 'numeric', + description: + 'Casts the `number` to a string and formats it to a decimal representation as specified by the `picture` string.\n\nThe behaviour of this function is consistent with the XPath/XQuery function [fn:format-number](https://www.w3.org/TR/xpath-functions-31/#func-format-number) as defined in the XPath F&O 3.1 specification. The picture string parameter defines how the number is formatted and has the [same syntax](https://www.w3.org/TR/xpath-functions-31/#syntax-of-picture-string) as fn:format-number.\n\nThe optional third argument `options` is used to override the default locale specific formatting characters such as the decimal separator. If supplied, this argument must be an object containing name/value pairs specified in the [decimal format](https://www.w3.org/TR/xpath-functions-31/#defining-decimal-format) section of the XPath F&O 3.1 specification.\n\n__Examples__\n\n- `$formatNumber(12345.6, \'#,###.00\')` => `"12,345.60"` \n- `$formatNumber(1234.5678, "00.000e0")` => `"12.346e2"` \n- `$formatNumber(34.555, "#0.00;(#0.00)")` => `"34.56"` \n- `$formatNumber(-34.555, "#0.00;(#0.00)")` => `"(34.56)"` \n- `$formatNumber(0.14, "01%")` => `"14%"` \n- `$formatNumber(0.14, "###pm", {"per-mille": "pm"})` => `"140pm"` \n- `$formatNumber(1234.5678, "①①.①①①e①", {"zero-digit": "\\u245f"})` => `"①②.③④⑥e②"`', + }, + $formatBase: { + params: [ + { + name: 'number', + }, + { + name: 'radix', + optional: true, + }, + ], + category: 'numeric', + description: + 'Casts the `number` to a string and formats it to an integer represented in the number base specified by the `radix` argument. If `radix` is not specified, then it defaults to base 10. `radix` can be between 2 and 36, otherwise an error is thrown.\n\n__Examples__\n\n- `$formatBase(100, 2)` => `"1100100"`\n- `$formatBase(2555, 16)` => `"9fb"`', + }, + $formatInteger: { + params: [ + { + name: 'number', + }, + { + name: 'picture', + }, + ], + category: 'numeric', + description: + 'Casts the `number` to a string and formats it to an integer representation as specified by the `picture` string.\n\nThe behaviour of this function is consistent with the two-argument version of the XPath/XQuery function [fn:format-integer](https://www.w3.org/TR/xpath-functions-31/#func-format-integer) as defined in the XPath F&O 3.1 specification. The picture string parameter defines how the number is formatted and has the same syntax as fn:format-integer.\n\n__Examples__\n\n- `$formatInteger(2789, \'w\')` => `"two thousand, seven hundred and eighty-nine"`\n- `$formatInteger(1999, \'I\')` => `"MCMXCIX"`', + }, + $parseInteger: { + params: [ + { + name: 'string', + }, + { + name: 'picture', + }, + ], + category: 'numeric', + description: + "Parses the contents of the `string` parameter to an integer (as a JSON number) using the format specified by the `picture` string.\nThe picture string parameter has the same format as `$formatInteger`. Although the XPath specification does not have an equivalent\nfunction for parsing integers, this capability has been added to JSONata.\n\n__Examples__\n\n- `$parseInteger(\"twelve thousand, four hundred and seventy-six\", 'w')` => `12476`\n- `$parseInteger('12,345,678', '#,##0')` => `12345678`", + }, + $sum: { + params: [ + { + name: 'array', + }, + ], + category: 'aggregation', + description: + "Returns the arithmetic sum of an array of numbers. It is an error if the input array contains an item which isn't a number.\n\n__Example__\n\n- `$sum([5,1,3,7,4])` => `20`", + }, + $max: { + params: [ + { + name: 'array', + }, + ], + category: 'aggregation', + description: + "Returns the maximum number in an array of numbers. It is an error if the input array contains an item which isn't a number.\n\n__Example__\n\n- `$max([5,1,3,7,4])` => `7`", + }, + $min: { + params: [ + { + name: 'array', + }, + ], + category: 'aggregation', + description: + "Returns the minimum number in an array of numbers. It is an error if the input array contains an item which isn't a number.\n\n__Example__\n\n- `$min([5,1,3,7,4])` => `1`", + }, + $average: { + params: [ + { + name: 'array', + }, + ], + category: 'aggregation', + description: + "Returns the mean value of an array of numbers. It is an error if the input array contains an item which isn't a number.\n\n__Example__\n\n- `$average([5,1,3,7,4])` => `4`", + }, + $boolean: { + params: [ + { + name: 'arg', + }, + ], + category: 'boolean', + description: + 'Casts the argument to a Boolean using the following rules:\n \n| Argument type | Result |\n| ------------- | ------ |\n| Boolean | unchanged |\n| string: empty | `false`|\n| string: non-empty | `true` |\n| number: 0 | `false`|\n| number: non-zero | `true` |\n| null | `false`|\n| array: empty | `false` |\n| array: contains a member that casts to `true` | `true` |\n| array: all members cast to `false` | `false` |\n| object: empty | `false` |\n| object: non-empty | `true` |\n| function | `false` |', + }, + $not: { + params: [ + { + name: 'arg', + }, + ], + category: 'boolean', + description: 'Returns Boolean NOT on the argument. `arg` is first cast to a boolean', + }, + $exists: { + params: [ + { + name: 'arg', + }, + ], + category: 'boolean', + description: + 'Returns Boolean `true` if the arg expression evaluates to a value, or `false` if the expression does not match anything (e.g. a path to a non-existent field reference).', + }, + $count: { + params: [ + { + name: 'array', + }, + ], + category: 'array', + description: + 'Returns the number of items in the `array` parameter. If the `array` parameter is not an array, but rather a value of another JSON type, then the parameter is treated as a singleton array containing that value, and this function returns `1`.\n\nIf `array` is not specified, then the context value is used as the value of `array`.\n\n__Examples__\n- `$count([1,2,3,1])` => `4`\n- `$count("hello")` => 1', + }, + $append: { + params: [ + { + name: 'array1', + }, + { + name: 'array2', + }, + ], + category: 'array', + description: + 'Returns an array containing the values in `array1` followed by the values in `array2`. If either parameter is not an array, then it is treated as a singleton array containing that value.\n\n__Examples__\n- `$append([1,2,3], [4,5,6])` => `[1,2,3,4,5,6]`\n- `$append([1,2,3], 4)` => `[1,2,3,4]`\n- `$append("Hello", "World")` => `["Hello", "World"]`', + }, + $sort: { + params: [ + { + name: 'array', + }, + { + name: 'function', + optional: true, + }, + ], + category: 'array', + description: + 'Returns an array containing all the values in the `array` parameter, but sorted into order. If no `function` parameter is supplied, then the `array` parameter must contain only numbers or only strings, and they will be sorted in order of increasing number, or increasing unicode codepoint respectively.\n\nIf a comparator `function` is supplied, then is must be a function that takes two parameters:\n\n`function(left, right)`\n\nThis function gets invoked by the sorting algorithm to compare two values `left` and `right`. If the value of `left` should be placed after the value of `right` in the desired sort order, then the function must return Boolean `true` to indicate a swap. Otherwise it must return `false`.\n\n__Example__\n```\n$sort(Account.Order.Product, function($l, $r) {\n $l.Description.Weight > $r.Description.Weight\n})\n```\n\nThis sorts the products in order of increasing weight.\n\nThe sorting algorithm is *stable* which means that values within the original array which are the same according to the comparator function will remain in the original order in the sorted array.', + }, + $reverse: { + params: [ + { + name: 'array', + }, + ], + category: 'array', + description: + 'Returns an array containing all the values from the `array` parameter, but in reverse order.\n\n__Examples__\n- `$reverse(["Hello", "World"])` => `["World", "Hello"]`\n- `[1..5] ~> $reverse()` => `[5, 4, 3, 2, 1]`', + }, + $shuffle: { + params: [ + { + name: 'array', + }, + ], + category: 'array', + description: + 'Returns an array containing all the values from the `array` parameter, but shuffled into random order.\n\n__Examples__\n- `$shuffle([1..9])` => `[6, 8, 2, 3, 9, 5, 1, 4, 7]`', + }, + $distinct: { + params: [ + { + name: 'array', + }, + ], + category: 'array', + description: + 'Returns an array containing all the values from the `array` parameter, but with any duplicates removed. Values are tested for deep equality as if by using the [equality operator](comparison-operators#equals).\n\n__Examples__\n- `$distinct([1,2,3,3,4,3,5])` => `[1, 2, 3, 4, 5]`\n- `$distinct(Account.Order.Product.Description.Colour)` => `[ "Purple", "Orange", "Black" ]`', + }, + $zip: { + params: [ + { + name: 'array1', + }, + { + name: '...', + variable: true, + }, + ], + category: 'array', + description: + 'Returns a convolved (zipped) array containing grouped arrays of values from the `array1` ... `arrayN` arguments from index 0, 1, 2, etc.\n\nThis function accepts a variable number of arguments. The length of the returned array is equal to the length of the shortest array in the arguments.\n\n__Examples__\n- `$zip([1,2,3], [4,5,6])` => `[[1,4] ,[2,5], [3,6]]`\n- `$zip([1,2,3],[4,5],[7,8,9])` => `[[1,4,7], [2,5,8]]`', + }, + $keys: { + params: [ + { + name: 'object', + }, + ], + category: 'object', + description: + 'Returns an array containing the keys in the object. If the argument is an array of objects, then the array returned contains a de-duplicated list of all the keys in all of the objects.', + }, + $lookup: { + params: [ + { + name: 'object', + }, + { + name: 'key', + }, + ], + category: 'object', + description: + 'Returns the value associated with `key` in `object`. If the first argument is an array of objects, then all of the objects in the array are searched, and the values associated with all occurrences of `key` are returned.', + }, + $spread: { + params: [ + { + name: 'object', + }, + ], + category: 'object', + description: + 'Splits an `object` containing key/value pairs into an array of objects, each of which has a single key/value pair from the input `object`. If the parameter is an array of objects, then the resultant array contains an object for every key/value pair in every object in the supplied array.', + }, + $merge: { + params: [ + { + name: 'array', + }, + ], + category: 'object', + description: + 'Merges an array of objects into a single object containing all the key/value pairs from each of the objects in the input array. If any of the input objects contain the same key, then the returned object will contain the value of the last one in the array. It is an error if the input array contains an item that is not an object.', + }, + $each: { + params: [ + { + name: 'object', + }, + { + name: 'function', + }, + ], + category: 'object', + description: + 'Returns an array containing the values return by the `function` when applied to each key/value pair in the `object`.\n\nThe `function` parameter will get invoked with two arguments:\n\n`function(value, name)`\n\nwhere the `value` parameter is the value of each name/value pair in the object and `name` is its name. The `name` parameter is optional.\n\n__Examples__\n\n`$each(Address, function($v, $k) {$k & ": " & $v})`\n\n=>\n\n [\n "Street: Hursley Park",\n "City: Winchester",\n "Postcode: SO21 2JN"\n ]', + }, + $error: { + params: [ + { + name: 'message', + }, + ], + category: 'object', + description: 'Deliberately throws an error with an optional `message`', + }, + $assert: { + params: [ + { + name: 'condition', + }, + { + name: 'message', + }, + ], + category: 'object', + description: + 'If condition is true, the function returns undefined. If the condition is false, an exception is thrown with the message as the message of the exception.', + }, + $type: { + params: [ + { + name: 'value', + }, + ], + category: 'object', + description: + 'Evaluates the type of `value` and returns one of the following strings:\n* `"null"`\n* `"number"`\n* `"string"`\n* `"boolean"`\n* `"array"`\n* `"object"`\n* `"function"`\nReturns (non-string) `undefined` when `value` is `undefined`.', + }, + $now: { + params: [ + { + name: 'picture', + optional: true, + }, + { + name: 'timezone', + optional: true, + }, + ], + category: 'date', + description: + 'Generates a UTC timestamp in ISO 8601 compatible format and returns it as a string. All invocations of `$now()` within an evaluation of an expression will all return the same timestamp value.\n\nIf the optional `picture` and `timezone` parameters are supplied, then the current timestamp is formatted as described by the [`$fromMillis()`](#frommillis) function.\n\n__Examples__\n\n- `$now()` => `"2017-05-15T15:12:59.152Z"`', + }, + $millis: { + params: [], + category: 'date', + description: + 'Returns the number of milliseconds since the Unix *Epoch* (1 January, 1970 UTC) as a number. All invocations of `$millis()` within an evaluation of an expression will all return the same value.\n\n__Examples__ \n- `$millis()` => `1502700297574`', + }, + $fromMillis: { + params: [ + { + name: 'number', + }, + { + name: 'picture', + optional: true, + }, + { + name: 'timezone', + optional: true, + }, + ], + category: 'date', + description: + 'Convert the `number` representing milliseconds since the Unix *Epoch* (1 January, 1970 UTC) to a formatted string representation of the timestamp as specified by the `picture` string.\n\nIf the optional `picture` parameter is omitted, then the timestamp is formatted in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format.\n\nIf the optional `picture` string is supplied, then the timestamp is formatted occording to the representation specified in that string.\nThe behaviour of this function is consistent with the two-argument version of the XPath/XQuery function [fn:format-dateTime](https://www.w3.org/TR/xpath-functions-31/#func-format-dateTime) as defined in the XPath F&O 3.1 specification. The picture string parameter defines how the timestamp is formatted and has the [same syntax](https://www.w3.org/TR/xpath-functions-31/#date-picture-string) as fn:format-dateTime.\n\nIf the optional `timezone` string is supplied, then the formatted timestamp will be in that timezone. The `timezone` string should be in the\nformat "±HHMM", where ± is either the plus or minus sign and HHMM is the offset in hours and minutes from UTC. Positive offset for timezones\neast of UTC, negative offset for timezones west of UTC. \n\n__Examples__\n\n- `$fromMillis(1510067557121)` => `"2017-11-07T15:12:37.121Z"`\n- `$fromMillis(1510067557121, \'[M01]/[D01]/[Y0001] [h#1]:[m01][P]\')` => `"11/07/2017 3:12pm"`\n- `$fromMillis(1510067557121, \'[H01]:[m01]:[s01] [z]\', \'-0500\')` => `"10:12:37 GMT-05:00"`', + }, + $toMillis: { + params: [ + { + name: 'timestamp', + }, + { + name: 'picture', + optional: true, + }, + ], + category: 'date', + description: + 'Convert a `timestamp` string to the number of milliseconds since the Unix *Epoch* (1 January, 1970 UTC) as a number. \n\nIf the optional `picture` string is not specified, then the format of the timestamp is assumed to be [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html). An error is thrown if the string is not in the correct format.\n\nIf the `picture` string is specified, then the format is assumed to be described by this picture string using the [same syntax](https://www.w3.org/TR/xpath-functions-31/#date-picture-string) as the XPath/XQuery function [fn:format-dateTime](https://www.w3.org/TR/xpath-functions-31/#func-format-dateTime), defined in the XPath F&O 3.1 specification. \n\n__Examples__\n\n- `$toMillis("2017-11-07T15:07:54.972Z")` => `1510067274972`', + }, + $map: { + params: [ + { + name: 'array', + }, + { + name: 'function', + }, + ], + category: 'higher-order', + description: + 'Returns an array containing the results of applying the `function` parameter to each value in the `array` parameter.\n\nThe function that is supplied as the second parameter must have the following signature:\n\n`function(value [, index [, array]])`\n\nEach value in the input array is passed in as the first parameter in the supplied function. The index (position) of that value in the input array is passed in as the second parameter, if specified. The whole input array is passed in as the third parameter, if specified.\n\n__Examples__\n- `$map([1..5], $string)` => `["1", "2", "3", "4", "5"]`\n\nWith user-defined (lambda) function:\n```\n$map(Email.address, function($v, $i, $a) {\n \'Item \' & ($i+1) & \' of \' & $count($a) & \': \' & $v\n})\n```\n\nevaluates to:\n\n```\n[\n "Item 1 of 4: fred.smith@my-work.com",\n "Item 2 of 4: fsmith@my-work.com",\n "Item 3 of 4: freddy@my-social.com",\n "Item 4 of 4: frederic.smith@very-serious.com"\n]\n```', + }, + $filter: { + params: [ + { + name: 'array', + }, + { + name: 'function', + }, + ], + category: 'higher-order', + description: + 'Returns an array containing only the values in the `array` parameter that satisfy the `function` predicate (i.e. `function` returns Boolean `true` when passed the value).\n\nThe function that is supplied as the second parameter must have the following signature:\n\n`function(value [, index [, array]])`\n\nEach value in the input array is passed in as the first parameter in the supplied function. The index (position) of that value in the input array is passed in as the second parameter, if specified. The whole input array is passed in as the third parameter, if specified.\n\n__Example__\nThe following expression returns all the products whose price is higher than average:\n```\n$filter(Account.Order.Product, function($v, $i, $a) {\n $v.Price > $average($a.Price)\n})\n```', + }, + $single: { + params: [ + { + name: 'array', + }, + { + name: 'function', + }, + ], + category: 'higher-order', + description: + 'Returns the one and only one value in the `array` parameter that satisfy the `function` predicate (i.e. `function` returns Boolean `true` when passed the value). Throws an exception if the number of matching values is not exactly one.\n\nThe function that is supplied as the second parameter must have the following signature:\n\n`function(value [, index [, array]])`\n\nEach value in the input array is passed in as the first parameter in the supplied function. The index (position) of that value in the input array is passed in as the second parameter, if specified. The whole input array is passed in as the third parameter, if specified.\n\n__Example__\nThe following expression the product in the order whose SKU is `"0406654608"`:\n```\n$single(Account.Order.Product, function($v, $i, $a) {\n $v.SKU = "0406654608"\n})\n```', + }, + $reduce: { + params: [ + { + name: 'array', + }, + { + name: 'function', + }, + { + name: 'init', + optional: true, + }, + ], + category: 'higher-order', + description: + 'Returns an aggregated value derived from applying the `function` parameter successively to each value in `array` in combination with the result of the previous application of the function.\n\nThe `function` must accept at least two arguments, and behaves like an infix operator between each value within the `array`. The signature of this supplied function must be of the form:\n\n`myfunc($accumulator, $value[, $index[, $array]])`\n\n__Example__\n\n```\n(\n $product := function($i, $j){$i * $j};\n $reduce([1..5], $product)\n)\n```\n\nThis multiplies all the values together in the array `[1..5]` to return `120`.\n\nIf the optional `init` parameter is supplied, then that value is used as the initial value in the aggregation (fold) process. If not supplied, the initial value is the first value in the `array` parameter.', + }, + $sift: { + params: [ + { + name: 'object', + }, + { + name: 'function', + }, + ], + category: 'higher-order', + description: + 'Returns an object that contains only the key/value pairs from the `object` parameter that satisfy the predicate `function` passed in as the second parameter.\n\nIf `object` is not specified, then the context value is used as the value of `object`. It is an error if `object` is not an object.\n\nThe function that is supplied as the second parameter must have the following signature:\n\n`function(value [, key [, object]])`\n\nEach value in the input object is passed in as the first parameter in the supplied function. The key (property name) of that value in the input object is passed in as the second parameter, if specified. The whole input object is passed in as the third parameter, if specified.\n\n__Example__\n\n```\nAccount.Order.Product.$sift(function($v, $k) {$k ~> /^Product/})\n```\n\nThis sifts each of the `Product` objects such that they only contain the fields whose keys start with the string "Product" (using a regex). This example returns:\n\n```\n[\n {\n "Product Name": "Bowler Hat",\n "ProductID": 858383\n },\n {\n "Product Name": "Trilby hat",\n "ProductID": 858236\n },\n {\n "Product Name": "Bowler Hat",\n "ProductID": 858383\n },\n {\n "ProductID": 345664,\n "Product Name": "Cloak"\n }\n]\n```', + }, + $random: { + params: [ + { + name: 'seed', + optional: true, + }, + ], + category: 'numeric', + description: + 'Returns a pseudo random number greater than or equal to zero and less than one (`0 ≤ n < 1`) The optional `seed` argument specifies a seed to use. Note that if you use this function with the same seed value, it will return identical numbers.\n\n__Examples__ \n- `$random()` => `0.7973541067127` \n- `$random()` => `0.4029142127028` \n- `$random()` => `0.6558078550072` \n- `$random(80)` => `0.7790078667647` \n- `$random(80)` => `0.7790078667647`', + }, + $partition: { + params: [ + { + name: 'array', + }, + { + name: 'size', + }, + ], + category: 'array', + description: + 'Partitions the input array into chunks of the specified size. The last chunk may be smaller than the specified size if the input array length is not evenly divisible by the chunk size.\n\n**Examples**\n\n- `$partition([1, 2, 3, 4, 5], 2) => [[1, 2], [3, 4], [5]]`\n- `$partition([1, 2, 3, 4, 5], 1) => [[1], [2], [3], [4], [5]]`\n- `$partition([1, 2, 3, 4, 5], 5) => [[1, 2, 3, 4, 5]]`\n- `$partition([1, 2, 3, 4, 5], 6) => [[1, 2, 3, 4, 5]]`\n- `$partition([], 2) => []`', + }, + $range: { + params: [ + { + name: 'start', + }, + { + name: 'stop', + }, + { + name: 'delta', + }, + ], + category: 'array', + description: + 'Generates a new array containing a specific range of elements. The `start` and `end` parameters specify the inclusive start and exclusive end of the range, and the `delta` parameter specifies the increment between each element.\n\n**Examples**\n\n- `$range(0, 10, 1) => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`\n- `$range(1, -5, -1) => [1, 0, -1, -2, -3, -4, -5]`\n- `$range(1, 9, 3) => [1, 4, 7]`\n- `$range(1, 1, 2) => [1]`\n- `$range(1, 9, 0) => []`', + }, + $hash: { + params: [ + { + name: 'str', + }, + { + name: 'algorithm', + }, + ], + category: 'string', + description: + 'Calculates the hash value of the input string using the specified hashing `algorithm`. The `algorithm` parameter can be one of `"MD5"`, `"SHA-1"`, `"SHA-256"`, `"SHA-384"`, or `"SHA-512"`.\n\n**Examples**\n\n- `$hash("input data", "SHA-1") => "aaff4a450a104cd177d28d18d7485e8cae074b7"`', + }, + $uuid: { + params: [], + category: 'string', + description: + 'Returns a randomly generated UUID version 4.\n\n**Examples**\n\n- `$uuid() => "ca4c1140-dcc1-40cd-ad05-7b4aa23df4a8"`', + }, + $parse: { + params: [ + { + name: 'str', + }, + ], + category: 'object', + description: + 'Deserializes the input JSON string.\n\n**Examples**\n\n- `$parse(\'{"foo": "bar"}\') => {"foo": "bar"}`', + }, +} + +export const jsonataFunctions: JsonataFunctionsMap = new Map(Object.entries(jsonataFunctionsList)) diff --git a/src/asl-utils/utils/jsonata/index.ts b/src/asl-utils/utils/jsonata/index.ts new file mode 100644 index 000000000..dce56963a --- /dev/null +++ b/src/asl-utils/utils/jsonata/index.ts @@ -0,0 +1,7 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +export * from './jsonata' +export type { FunctionCategory, FunctionParam, FunctionType, JsonataFunctionsMap } from './functions' diff --git a/src/asl-utils/utils/jsonata/jsonata.ts b/src/asl-utils/utils/jsonata/jsonata.ts new file mode 100644 index 000000000..814b19b50 --- /dev/null +++ b/src/asl-utils/utils/jsonata/jsonata.ts @@ -0,0 +1,170 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import type jsonata from 'jsonata' +import type { FunctionParam, JsonataFunctionsMap } from './functions' + +// There are additional properties in the object not specified in the library's typescript interface +export interface ExprNode extends jsonata.ExprNode { + body?: jsonata.ExprNode + then?: jsonata.ExprNode + else?: jsonata.ExprNode + condition?: jsonata.ExprNode + error?: jsonata.JsonataError +} + +export interface JSONataASTResult { + node: ExprNode + parent: ExprNode | null +} + +export const exprPropertiesToRecurse = [ + 'arguments', + 'procedure', + 'expressions', + 'stages', + 'lhs', + 'rhs', + 'body', + 'stages', + 'steps', + 'then', + 'else', + 'condition', +] satisfies (keyof ExprNode)[] + +export const JSONATA_TEMPLATE_WRAPPER = { + start: '{%', + end: '%}', +} as const + +export const MAX_AST_DEPTH = 100 + +function findNodeInASTRecursive( + ast: ExprNode | ExprNode[], + parent: ExprNode | null, + position: number, + depth = 0, +): JSONataASTResult | null { + if (depth > MAX_AST_DEPTH) { + return null + } + + // Some AST children are arrays, so we should iterate through each of the children + if (Array.isArray(ast)) { + let currentNode: ExprNode | null = null + for (const node of ast) { + const currentPosition = node.position || node.error?.position + if (!currentPosition) { + const found = findNodeInASTRecursive(node, parent, position, depth + 1) + if (found) { + return found + } + + continue + } + + if (currentPosition > position) { + continue + } + + if (currentPosition === position) { + return { + node: node, + parent, + } + } + + if (!currentNode?.position || (currentPosition < position && currentNode.position < currentPosition)) { + currentNode = node + } + } + + if (!currentNode) { + return null + } + + return findNodeInASTRecursive(currentNode, parent, position, depth + 1) + } + + if (ast.position && ast.position > position) { + return null + } + + if (ast.position === position) { + return { + node: ast, + parent, + } + } + + for (const key of exprPropertiesToRecurse) { + const value = ast[key] + if (value) { + const found = findNodeInASTRecursive(value, ast, position, depth + 1) + if (found) { + return found + } + } + } + + return null +} + +let jsonataLibrary: typeof import('jsonata') | null = null + +/** + * Dynamically imports jsonata and gets the AST of the string + * @param input JSONata string to parse + * @returns The root node of the JSONata AST + */ +export async function getJSONataAST(input: string): Promise { + if (!jsonataLibrary) { + jsonataLibrary = (await import('jsonata')).default + } + + return jsonataLibrary(input, { + recover: true, + }).ast() +} + +export async function getJSONataFunctionList(): Promise { + return (await import('./functions')).jsonataFunctions +} + +/** + * Searches the JSONata AST to find the node at a specified position. If the AST has nodes + * that have a position past the position parameter, this function will return null. + * @param ast The JSONata AST provided by the JSONata library + * @param position The position to search the JSONata AST + * @returns The node at the position + */ +export function findNodeInJSONataAST(ast: ExprNode, position: number): JSONataASTResult | null { + return findNodeInASTRecursive(ast, null, position) +} + +/** + * Generates the function argument string from a given list of JSONata function parameters. + * Recursively surrounds optional arguments with [] and separates arguments with commas. + * @param functionParams Function parameters properties used for generating the argument string + * @param index The index of the parameter to start traversal at + * @returns The function argument string + */ +export function getFunctionArguments(functionParams: ReadonlyArray, index = 0): string { + if (index > functionParams.length - 1) { + return '' + } + + const prefix = index === 0 ? '' : ', ' + const argument = functionParams[index].name + + const info = `${prefix}${argument}${getFunctionArguments(functionParams, index + 1)}` + + if (functionParams[index].optional) { + return `[${info}]` + } + + return info +} diff --git a/src/asl-utils/utils/tests/assignVariableTestData.ts b/src/asl-utils/utils/tests/assignVariableTestData.ts new file mode 100644 index 000000000..883a063f0 --- /dev/null +++ b/src/asl-utils/utils/tests/assignVariableTestData.ts @@ -0,0 +1,302 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { Asl } from '../../asl/definitions' +import { DistributedMapProcessingMode } from '../../asl/definitions' + +export const getMapAsl: (mode: DistributedMapProcessingMode) => Asl = (mode) => { + return { + Comment: 'Assign variable with map states', + StartAt: 'Pass_Parent', + States: { + Pass_Parent: { + Type: 'Pass', + Next: 'Map', + Assign: { + var_parent: 1, + }, + }, + Map: { + Type: 'Map', + Iterator: { + StartAt: 'Pass_SubWorkflow1', + States: { + Pass_SubWorkflow1: { + Type: 'Pass', + Next: 'Pass_SubWorkflow2', + Assign: { + var_sub1: 1, + }, + }, + Pass_SubWorkflow2: { + Type: 'Pass', + End: true, + Assign: { + var_sub2: 1, + }, + }, + }, + ProcessorConfig: { + Mode: mode, + ExecutionType: 'STANDARD', + }, + }, + End: true, + ItemsPath: '$', + MaxConcurrency: 5, + Assign: { + map: 'map params', + }, + }, + }, + } +} + +export const mockLocalScopeAsl: Asl = { + Comment: 'A description of my state machine', + StartAt: 'Pass_BeforeLambda', + States: { + Pass_BeforeLambda: { + Type: 'Pass', + Next: 'Lambda', + Assign: { + ValueEnteredInForm: '{\n "brancOne2.$": "$.branch1",\n}', + var_pass_before: 1, + var_nested: { + array: [ + { + key1: 123, + }, + ], + object: { + nestedObjectKey: 1, + }, + }, + }, + }, + Lambda: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + OutputPath: '$.Payload', + Parameters: { + 'Payload.$': '$', + }, + Assign: { + var_lambda_pass: 1, + }, + Next: 'Pass_Success', + Catch: [ + { + ErrorEquals: [], + Next: 'Pass_ErrorFallback', + Assign: { + var_lambda_error: 1, + }, + }, + ], + }, + Pass_Success: { + Type: 'Pass', + Assign: { + var_pass_success: 1, + }, + Next: 'Pass_End', + }, + Pass_ErrorFallback: { + Type: 'Pass', + Next: 'Pass_End', + Assign: { + var_pass_error: 1, + }, + }, + Pass_End: { + Type: 'Pass', + Next: 'Success', + Assign: { + var_pass_end: 1, + }, + }, + Success: { + Type: 'Succeed', + }, + }, +} + +export const mockParallelAssign: Asl = { + Comment: 'A description of my parallel state machine', + StartAt: 'Pass_Parent', + States: { + Pass_Parent: { + Type: 'Pass', + Next: 'Parallel', + Assign: { + var_parent: 1, + }, + }, + Parallel: { + Type: 'Parallel', + Branches: [ + { + StartAt: 'Branch2-1', + States: { + 'Branch2-1': { + Type: 'Pass', + Assign: { + var_branch2_1: 1, + }, + End: true, + }, + }, + }, + { + StartAt: 'Branch1-1', + States: { + 'Branch1-1': { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + OutputPath: '$.Payload', + Parameters: { + 'Payload.$': '$', + }, + Assign: { + var_branch1_1: 1, + }, + Next: 'Branch1-2', + }, + 'Branch1-2': { + Type: 'Pass', + Assign: { + var_branch1_2: 1, + }, + End: true, + }, + }, + }, + ], + End: true, + Assign: { + var_parallel: 1, + }, + }, + }, +} + +export const mockChoiceAssignAsl: Asl = { + Comment: 'A description of my state machine', + StartAt: 'Pass_Before_Choice', + States: { + Pass_Parent: { + Type: 'Pass', + Next: 'Choice', + Assign: { + var_pass_before: 1, + }, + }, + Choice: { + Type: 'Choice', + Choices: [ + { + Next: 'Rule1-1', + Assign: { + var_rule1: 1, + }, + }, + ], + Default: 'Rule-default-1', + Assign: { + var_rule_default: 1, + }, + }, + 'Rule1-1': { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + OutputPath: '$.Payload', + Parameters: { + 'Payload.$': '$', + }, + Assign: { + var_branch_1: 1, + }, + Next: 'Rule1-2', + }, + 'Rule1-2': { + Type: 'Pass', + Assign: { + var_branch_2: 1, + }, + End: true, + }, + 'Rule-default-1': { + Type: 'Pass', + Assign: { + var_default_1: 1, + }, + End: true, + }, + }, +} + +export const mockManualLoopAsl: Asl = { + Comment: 'A description of my state machine', + StartAt: 'Wait_BeforeLoop', + States: { + Wait_BeforeLoop: { + Type: 'Wait', + Seconds: 5, + Next: 'Pass_Before_Choice', + Assign: { + var_before_loop: 1, + }, + }, + Pass_Before_Choice: { + Type: 'Pass', + Next: 'Choice', + Assign: { + var_pass_before: '$', + }, + }, + Choice: { + Type: 'Choice', + Choices: [ + { + Next: 'Rule1-1', + Assign: { + var_rule1: 1, + }, + }, + ], + Default: 'Rule-default-1', + Assign: { + var_rule_default: 1, + }, + }, + 'Rule1-1': { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + OutputPath: '$.Payload', + Parameters: { + 'Payload.$': '$', + }, + Assign: { + var_branch_1: 1, + }, + Next: 'Rule1-2', + }, + 'Rule1-2': { + Type: 'Pass', + Assign: { + var_branch_2: 1, + }, + End: true, + }, + 'Rule-default-1': { + Type: 'Pass', + Assign: { + var_default_1: 1, + }, + Next: 'Pass_Before_Choice', + }, + }, +} diff --git a/src/asl-utils/utils/tests/autocomplete.test.ts b/src/asl-utils/utils/tests/autocomplete.test.ts new file mode 100644 index 000000000..62db7ba6e --- /dev/null +++ b/src/asl-utils/utils/tests/autocomplete.test.ts @@ -0,0 +1,448 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { DistributedMapProcessingMode } from '../../asl/definitions' +import { + getMapAsl, + mockLocalScopeAsl, + mockParallelAssign, + mockChoiceAssignAsl, + mockManualLoopAsl, +} from './assignVariableTestData' +import { + VariableCompletionList, + buildPreviousStatesMap, + getAssignCompletionList, + getCompletionStrings, + getReservedVariables, + GetReservedVariablesParams, + getJSONataMacroContent, +} from '../autocomplete' + +describe('autocomplete', () => { + describe('getAssignCompletionList - Local Scope', () => { + beforeAll(() => { + buildPreviousStatesMap(mockLocalScopeAsl) + }) + + it('Should return empty list if stateId not exist', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'NotAState', 'NotAState') + expect(autocomplete.localScope).toEqual({}) + expect(autocomplete.outerScope).toEqual({}) + }) + + it('Should see nested object key in previous assigned variables', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Lambda', 'Lambda') + expect(autocomplete.localScope).toHaveProperty('var_nested.object.nestedObjectKey') + expect(autocomplete.localScope).toHaveProperty('var_nested.array[0].key1') + }) + + it('Should not see variables assigned at current state and in states after current state', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Lambda', 'Lambda') + expect(autocomplete.localScope).not.toHaveProperty('var_lambda_pass') + expect(autocomplete.localScope).not.toHaveProperty('var_lambda_error') + expect(autocomplete.localScope).not.toHaveProperty('var_pass_success') + }) + + it('Should not see JSON_EDITING_PROPERTY', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Lambda', 'Lambda') + expect(autocomplete.localScope).not.toHaveProperty('ValueEnteredInForm') + }) + + it('Should see error assigned variables in error catcher and not default assign in fallback state', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Pass_ErrorFallback', 'Pass_ErrorFallback') + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + expect(autocomplete.localScope).toHaveProperty('var_lambda_error') + expect(autocomplete.localScope).not.toHaveProperty('var_lambda_pass') + }) + + it('Should see default assign variables but not error catcher Assign variables in next state', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Pass_Success', 'Pass_Success') + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + expect(autocomplete.localScope).toHaveProperty('var_lambda_pass') + expect(autocomplete.localScope).not.toHaveProperty('var_lambda_error') + }) + + it('Should see merge of assign variables for node with multiple previous nodes', () => { + const autocomplete = getAssignCompletionList(mockLocalScopeAsl, 'Pass_End', 'Pass_End') + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + expect(autocomplete.localScope).toHaveProperty('var_lambda_pass') + expect(autocomplete.localScope).toHaveProperty('var_pass_success') + expect(autocomplete.localScope).toHaveProperty('var_pass_error') + }) + }) + + describe('getAssignCompletionList - Choice state', () => { + beforeAll(() => { + buildPreviousStatesMap(mockChoiceAssignAsl) + }) + + it('Should see assigned variables from previous stasks as local scope', () => { + const autocomplete = getAssignCompletionList(mockChoiceAssignAsl, 'Rule1-2', 'Rule1-2') + expect(autocomplete.localScope).toHaveProperty('var_branch_1') + expect(autocomplete.localScope).toHaveProperty('var_rule1') + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + + // will not see variable assigned in current state + expect(autocomplete.localScope).not.toHaveProperty('var_branch_2') + expect(autocomplete.outerScope).not.toHaveProperty('var_branch_2') + }) + + it('Should see assigned variables from default Assign for tasks in default choice rule', () => { + const autocomplete = getAssignCompletionList(mockChoiceAssignAsl, 'Rule-default-1', 'Rule-default-1') + + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + expect(autocomplete.localScope).toHaveProperty('var_rule_default') + }) + + it('Should not see assigned variables from state after other choice rules if states are not connected', () => { + const autocomplete = getAssignCompletionList(mockChoiceAssignAsl, 'Rule1-2', 'Rule1-2') + expect(autocomplete.localScope).not.toHaveProperty('var_rule_default') + expect(autocomplete.outerScope).not.toHaveProperty('var_rule_default') + + expect(autocomplete.localScope).not.toHaveProperty('var_default_1') + expect(autocomplete.outerScope).not.toHaveProperty('var_default_1') + }) + }) + + describe('getAssignCompletionList - Map State', () => { + const inlinedMapAsl = getMapAsl(DistributedMapProcessingMode.Inline) + const distributedMapAsl = getMapAsl(DistributedMapProcessingMode.Distributed) + + it('Should only see local assigned variables in sub-workflows of distribued map state', () => { + buildPreviousStatesMap(distributedMapAsl) + const autocomplete = getAssignCompletionList(distributedMapAsl, 'Pass_SubWorkflow2', 'Pass_SubWorkflow2') + expect(autocomplete.localScope).toHaveProperty('var_sub1') + expect(autocomplete.outerScope).not.toHaveProperty('var_parent') + }) + + it('Should see local and outer scope assigned variables in sub-workflows of inlined map', () => { + buildPreviousStatesMap(inlinedMapAsl) + const autocomplete = getAssignCompletionList(inlinedMapAsl, 'Pass_SubWorkflow2', 'Pass_SubWorkflow2') + expect(autocomplete.localScope).toHaveProperty('var_sub1') + expect(autocomplete.outerScope).toHaveProperty('var_parent') + + // should not see assigned variables of current state + expect(autocomplete.outerScope).not.toHaveProperty('var_sub2') + expect(autocomplete.localScope).not.toHaveProperty('var_sub2') + }) + + /** variables will be assigned after sub-workflow finished */ + it('Should not see assigned variables of map state from sub-workflows', () => { + const autocomplete = getAssignCompletionList(inlinedMapAsl, 'Pass_SubWorkflow2', 'Pass_SubWorkflow2') + expect(autocomplete.outerScope).not.toHaveProperty('var_map') + }) + }) + + describe('getAssignCompletionList - Prallel State', () => { + beforeAll(() => { + buildPreviousStatesMap(mockParallelAssign) + }) + + it('Should see assigned variables in both local scope and outer scope from sub-workflows', () => { + const autocomplete = getAssignCompletionList(mockParallelAssign, 'Branch1-2', 'Branch1-2') + expect(autocomplete.outerScope).toHaveProperty('var_parent') + expect(autocomplete.localScope).toHaveProperty('var_branch1_1') + }) + + it('Should not see assigned variables in parallel branch', () => { + const autocomplete = getAssignCompletionList(mockParallelAssign, 'Branch1-2', 'Branch1-2') + expect(autocomplete.outerScope).not.toHaveProperty('var_branch1_2') + expect(autocomplete.localScope).not.toHaveProperty('var_branch1_2') + }) + + it('Should not see assigned variables of parallel state from sub-workflows', () => { + const autocomplete = getAssignCompletionList(mockParallelAssign, 'Branch1-2', 'Branch1-2') + expect(autocomplete.outerScope).not.toHaveProperty('var_parallel') + expect(autocomplete.localScope).not.toHaveProperty('var_parallel') + }) + }) + + describe('getAssignCompletionList - Manual loop', () => { + beforeAll(() => { + buildPreviousStatesMap(mockManualLoopAsl) + }) + + it('Should not see variables from states in manual loop before manual loop', () => { + const autocomplete = getAssignCompletionList(mockManualLoopAsl, 'Wait_BeforeLoop', 'Wait_BeforeLoop') + expect(autocomplete.localScope).toEqual({}) + expect(autocomplete.outerScope).toEqual({}) + }) + + it('Should see all variables from states in manual loop for a state in manual loop', () => { + const autocomplete = getAssignCompletionList(mockManualLoopAsl, 'Pass_Before_Choice', 'Pass_Before_Choice') + expect(autocomplete.localScope).toHaveProperty('var_default_1') + expect(autocomplete.localScope).toHaveProperty('var_rule_default') + + expect(autocomplete.localScope).not.toHaveProperty('var_rule1') + expect(autocomplete.localScope).not.toHaveProperty('var_branch_1') + }) + + it('Should see all variables from states in manual loop for state after manual loop', () => { + const autocomplete = getAssignCompletionList(mockManualLoopAsl, 'Rule1-2', 'Rule1-2') + expect(autocomplete.localScope).toHaveProperty('var_default_1') + expect(autocomplete.localScope).toHaveProperty('var_rule_default') + expect(autocomplete.localScope).toHaveProperty('var_pass_before') + expect(autocomplete.localScope).toHaveProperty('var_rule1') + expect(autocomplete.localScope).toHaveProperty('var_branch_1') + }) + }) + + describe('getReservedVariables', () => { + it('Should get reserved variables in task input field', () => { + const variable = getReservedVariables({ + stateType: 'Task', + }) + expect(variable).toHaveProperty('states.input') + expect(variable).toHaveProperty('states.context') + expect(variable).not.toHaveProperty('states.errorOutput') + expect(variable).not.toHaveProperty('states.result') + }) + + it('Should get error reserved variables in task error field', () => { + const variable = getReservedVariables({ + stateType: 'Task', + isError: true, + }) + expect(variable).toHaveProperty('states.input') + expect(variable).toHaveProperty('states.context') + expect(variable).toHaveProperty('states.errorOutput') + expect(variable).not.toHaveProperty('states.result') + }) + + it('Should get result reserved variables in task success field', () => { + const variable = getReservedVariables({ + stateType: 'Task', + isSuccess: true, + }) + expect(variable).toHaveProperty('states.input') + expect(variable).toHaveProperty('states.context') + expect(variable).not.toHaveProperty('states.errorOutput') + expect(variable).toHaveProperty('states.result') + }) + + it('Should get result reserved variables for map item selector field', () => { + const variable = getReservedVariables({ + stateType: 'Task', + isItemSelector: true, + }) + expect(variable).toHaveProperty('states.input') + expect(variable).toHaveProperty('states.context.Map') + expect(variable).not.toHaveProperty('states.errorOutput') + expect(variable).not.toHaveProperty('states.result') + }) + }) + + describe('getCompletionStrings', () => { + const completionScope: VariableCompletionList = { + localScope: { + 'innerVar.$': null, + innerArray: [ + { + arrayVal: null, + arrayVal2: null, + }, + ], + }, + outerScope: { + outerObject: { + nestedVar: null, + nestedVar2: null, + }, + }, + } + + const reservedVariablesParams: GetReservedVariablesParams = { + stateType: 'Task', + } + + it('Should get a list of child property in nested object', () => { + const completionStrings = getCompletionStrings({ + nodeVal: '$outerObject.n', + completionScope, + reservedVariablesParams, + }) + expect(completionStrings.items).toContain('nestedVar') + expect(completionStrings.items).toContain('nestedVar2') + expect(completionStrings.parentPath).toEqual('outerObject') + }) + + it('Should get a list field name in root level when input is empty', () => { + const completionStrings = getCompletionStrings({ + nodeVal: '', + completionScope, + reservedVariablesParams, + }) + expect(completionStrings.items).toContain('$outerObject') + expect(completionStrings.items).toContain('$innerVar') + expect(completionStrings.items).toContain('$innerArray') + expect(completionStrings.items).toContain('$states') + }) + + it('Should get a list field name for object inside an array', () => { + const completionStrings = getCompletionStrings({ + nodeVal: '$innerArray[0].array', + completionScope, + reservedVariablesParams, + }) + expect(completionStrings.parentPath).toEqual('innerArray[0]') + expect(completionStrings.items).toContain('arrayVal') + expect(completionStrings.items).toContain('arrayVal2') + }) + + it('Should get a list of child property in nested object', () => { + const completionStrings = getCompletionStrings({ + nodeVal: '$outerObject.n', + completionScope, + reservedVariablesParams, + }) + expect(completionStrings.items).toContain('nestedVar') + expect(completionStrings.items).toContain('nestedVar2') + expect(completionStrings.parentPath).toEqual('outerObject') + }) + }) + + describe('getJSONataMacroContent', () => { + it('should return null if text is a JSONata expression', () => { + const result = getJSONataMacroContent('{%%}') + expect(result).toBeNull() + }) + + it.each(['arn:aws', 'Hello world', '1'])('should return null if text is %s', (text) => { + const result = getJSONataMacroContent(text) + expect(result).toBeNull() + }) + + it('should return null if text is valid JSON', () => { + const result = getJSONataMacroContent(JSON.stringify({ a: 1 })) + expect(result).toBeNull() + }) + + it('should return null if text includes new lines', () => { + const result = getJSONataMacroContent('{\n"text"}') + expect(result).toBeNull() + }) + + it('should return macro content for {%', () => { + const result = getJSONataMacroContent('{%') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": false, +} +`) + }) + + it('should return macro content for " {%"', () => { + const result = getJSONataMacroContent(' {%') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": false, +} +`) + }) + + it('should return macro content for {%%', () => { + const result = getJSONataMacroContent('{%%') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": false, +} +`) + }) + + it('should return macro content for "{% %} "', () => { + const result = getJSONataMacroContent('{% %} ') + expect(result).toMatchInlineSnapshot(` +{ + "content": " ", + "isTypo": false, +} +`) + }) + + it('should return macro content for {% $states.', () => { + const result = getJSONataMacroContent('{% $states.') + expect(result).toMatchInlineSnapshot(` +{ + "content": " $states.", + "isTypo": false, +} +`) + }) + + it('should return macro content for %', () => { + const result = getJSONataMacroContent('%') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": true, +} +`) + }) + + it('should return macro content for %{', () => { + const result = getJSONataMacroContent('%{') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": true, +} +`) + }) + + it('should return macro content for " %{"', () => { + const result = getJSONataMacroContent(' %{') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": true, +} +`) + }) + + it('should return macro content for "%{ $states. }%"', () => { + const result = getJSONataMacroContent('%{ $states. }%') + expect(result).toMatchInlineSnapshot(` +{ + "content": " $states. ", + "isTypo": true, +} +`) + }) + + it('should return macro content for "{% %} "', () => { + const result = getJSONataMacroContent('{% %} ') + expect(result).toMatchInlineSnapshot(` +{ + "content": " ", + "isTypo": false, +} +`) + }) + + it('should return macro content for %{ $states.', () => { + const result = getJSONataMacroContent('%{ $states.') + expect(result).toMatchInlineSnapshot(` +{ + "content": " $states.", + "isTypo": true, +} +`) + }) + + it('should return macro content for %}%', () => { + const result = getJSONataMacroContent('%}%') + expect(result).toMatchInlineSnapshot(` +{ + "content": "", + "isTypo": true, +} +`) + }) + }) +}) diff --git a/src/asl-utils/utils/tests/jsonata.test.ts b/src/asl-utils/utils/tests/jsonata.test.ts new file mode 100644 index 000000000..0349578d9 --- /dev/null +++ b/src/asl-utils/utils/tests/jsonata.test.ts @@ -0,0 +1,424 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { FunctionParam } from '../../utils/jsonata' +import { + MAX_AST_DEPTH, + exprPropertiesToRecurse, + findNodeInJSONataAST, + getFunctionArguments, + getJSONataAST, + getJSONataFunctionList, +} from '../jsonata/jsonata' + +// eslint-disable-next-line no-var +var jsonataSpy: jest.SpyInstance | undefined = undefined +jest.mock('jsonata', () => { + const original = jest.requireActual('jsonata') + jsonataSpy = jest.fn(original) + return { + __esModule: true, + default: jsonataSpy, + } +}) + +describe('jsonataHelper', () => { + describe('getJSONataAST', () => { + it('should call JSONata library function with the correct parameters', async () => { + const result = await getJSONataAST('myString') + expect(jsonataSpy).toHaveBeenCalledWith('myString', { + recover: true, + }) + + expect(result).toBeTruthy() + }) + }) + + describe('findNodeInJSONataAST', () => { + it('should return the correct node with just $ expression', async () => { + const expression = '$' + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + value: '', + type: 'variable', + position: expression.length, + }, + parent: null, + }) + }) + + it('should return the correct node with simple variable expression', async () => { + const expression = '$length' + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + value: 'length', + type: 'variable', + position: expression.length, + }, + parent: null, + }) + }) + + it('should return the correct node with nested function argument', async () => { + const expression = '$length($' + + const ast = await getJSONataAST(expression) + expect(ast.arguments).toBeTruthy() + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + value: '', + type: 'variable', + position: expression.length, + }, + parent: ast, + }) + }) + + it('should return the correct node with multipled function arguments', async () => { + const expression = '$length($myVar1, $myVar2, $myVar3' + + const ast = await getJSONataAST(expression) + expect(ast.arguments).toBeTruthy() + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + value: 'myVar3', + type: 'variable', + position: expression.length, + }, + parent: ast, + }) + }) + + it('should return the correct node with a property path', async () => { + const expression = '$states.context.' + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + type: 'error', + error: { + code: expect.any(String), + position: expression.length, + token: expect.any(String), + }, + }, + parent: { + type: 'path', + steps: expect.any(Array), + }, + }) + }) + + it('should return null if the recursion is too deep', async () => { + const expression = '$a('.repeat(MAX_AST_DEPTH + 1) + ')'.repeat(MAX_AST_DEPTH + 1) + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual(null) + }) + + it('should return null if a node is greater than the current position', async () => { + const expression = '$states' + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, 1) + + expect(node).toEqual(null) + }) + + it('should return null if a nested node is greater than the current position', async () => { + const expression = '$states.context.' + + const ast = await getJSONataAST(expression) + + const node = findNodeInJSONataAST(ast, 1) + + expect(node).toEqual(null) + }) + + it('should return value for expression without position in AST', async () => { + const expression = '($states.' + + const ast = await getJSONataAST(expression) + + expect(ast).toEqual({ + type: 'block', + position: 1, + expressions: [ + { + type: 'path', + steps: expect.any(Array), + }, + ], + }) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + type: 'error', + error: { + code: expect.any(String), + position: expression.length, + token: expect.any(String), + }, + }, + parent: { + type: 'path', + steps: expect.any(Array), + }, + }) + }) + + it('should return value for multiple expressions without position in AST', async () => { + const expression = '($states.context; $length(' + + const ast = await getJSONataAST(expression) + + expect(ast).toEqual({ + type: 'block', + position: 1, + expressions: [ + { + type: 'path', + steps: expect.any(Array), + }, + { + type: 'function', + name: undefined, + value: expect.any(String), + position: 26, + arguments: expect.any(Array), + procedure: expect.any(Object), + }, + ], + }) + + const node = findNodeInJSONataAST(ast, expression.length) + + expect(node).toEqual({ + node: { + type: 'function', + name: undefined, + value: expect.any(String), + position: 26, + arguments: expect.any(Array), + procedure: expect.any(Object), + }, + parent: ast, + }) + }) + + it('should return value for function declarations', async () => { + const expression = '$product := function($a, $b) { $a * $b' + + const ast = await getJSONataAST(expression) + + expect(ast).toEqual({ + type: 'bind', + value: ':=', + position: 11, + lhs: { value: 'product', type: 'variable', position: 8 }, + rhs: { + type: 'lambda', + arguments: expect.any(Array), + signature: undefined, + position: 21, + body: { + type: 'binary', + value: '*', + position: 35, + lhs: expect.any(Object), + rhs: expect.any(Object), + }, + }, + }) + + const node = findNodeInJSONataAST(ast, expression.length) + expect(node).toEqual({ + node: { value: 'b', type: 'variable', position: expression.length }, + parent: { + type: 'binary', + value: '*', + position: 35, + lhs: expect.any(Object), + rhs: { value: 'b', type: 'variable', position: expression.length }, + }, + }) + }) + + it('should return value for ternary condition', async () => { + const expression = '$states ? $' + + const ast = await getJSONataAST(expression) + + expect(ast).toEqual({ + type: 'condition', + position: 9, + condition: { value: 'states', type: 'variable', position: 7 }, + then: { value: '', type: 'variable', position: expression.length }, + }) + + const node = findNodeInJSONataAST(ast, expression.length) + expect(node).toEqual({ + node: { value: '', type: 'variable', position: expression.length }, + parent: ast, + }) + }) + + it('should return value for ternary else', async () => { + const expression = '$states ? 3 : $' + + const ast = await getJSONataAST(expression) + + expect(ast).toEqual({ + type: 'condition', + position: 9, + condition: { value: 'states', type: 'variable', position: 7 }, + then: expect.any(Object), + else: { value: '', type: 'variable', position: expression.length }, + }) + + const node = findNodeInJSONataAST(ast, expression.length) + expect(node).toEqual({ + node: { value: '', type: 'variable', position: expression.length }, + parent: ast, + }) + }) + }) + + describe('exprPropertiesToRecurse', () => { + it.each(exprPropertiesToRecurse)('should return the correct node when recursing for %s', (propertyToRecurse) => { + const ast = { + type: 'function', + [propertyToRecurse]: { + value: 'myValue', + type: 'variable', + position: 100, + }, + } + + const node = findNodeInJSONataAST(ast, 100) + + expect(node).toEqual({ + node: { + value: 'myValue', + type: 'variable', + position: 100, + }, + parent: ast, + }) + + const astWithNestedArray = { + type: 'function', + [propertyToRecurse]: [ + { + value: 'myValue', + type: 'variable', + position: 100, + }, + ], + } + + const nodeFromArray = findNodeInJSONataAST(astWithNestedArray, 100) + expect(nodeFromArray).toEqual({ + node: { + value: 'myValue', + type: 'variable', + position: 100, + }, + parent: astWithNestedArray, + }) + }) + }) + + describe('getJSONataFunctionList', () => { + it('should return list of JSONata functions', async () => { + const functions = await getJSONataFunctionList() + expect(functions).toBeInstanceOf(Map) + expect(functions.size).toBeGreaterThan(0) + for (const [key, value] of functions.entries()) { + expect(typeof key).toEqual('string') + expect(value).toEqual({ + params: expect.any(Array), + category: expect.any(String), + description: expect.any(String), + }) + } + }) + }) + + describe('getFunctionArguments', () => { + it('should return the correct arguments for optional params', async () => { + const functionParams: FunctionParam[] = [ + { + name: 'myParam', + }, + { + name: 'myParam2', + }, + { + name: 'myParam3', + optional: true, + }, + ] + + expect(getFunctionArguments(functionParams)).toEqual('myParam, myParam2[, myParam3]') + }) + + it('should return the correct arguments for variable params', async () => { + const functionParams: FunctionParam[] = [ + { + name: 'myParam', + }, + { + name: '...', + }, + ] + + expect(getFunctionArguments(functionParams)).toEqual('myParam, ...') + }) + + it('should return the correct arguments for nested optional params', async () => { + const functionParams: FunctionParam[] = [ + { + name: 'myParam', + }, + { + name: 'myParam2', + optional: true, + }, + { + name: 'myParam3', + optional: true, + }, + ] + + expect(getFunctionArguments(functionParams)).toEqual('myParam[, myParam2[, myParam3]]') + }) + }) +}) diff --git a/src/asl-utils/utils/tests/utils.test.ts b/src/asl-utils/utils/tests/utils.test.ts new file mode 100644 index 000000000..3d7f8b5cf --- /dev/null +++ b/src/asl-utils/utils/tests/utils.test.ts @@ -0,0 +1,93 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import { deepClone, isJSONataExpression, isValidJSON, lastItem } from '../utils' + +describe('lastItem', () => { + it('should return the last item of array', () => { + expect(lastItem([])).toBeUndefined() + expect(lastItem([1])).toBe(1) + expect(lastItem([1, 2, 3])).toBe(3) + }) +}) +describe('deepClone', () => { + it('should deepClone objects', () => { + const obj = { + val: 1, + prop: { + val2: 0, + val3: { + name: 'a', + condition: false, + myArr: [1, 2, 3, 4], + myFunc: () => { + // noop + }, + [Symbol.iterator]: {}, + }, + }, + } + expect(JSON.stringify(deepClone(obj))).toBe(JSON.stringify(obj)) + }) +}) + +describe('isValidJSON', () => { + it('should return true if json is valid', () => { + const validJson = `{ + "a": "b", + "c": { + "d": "e" + } + }` + expect(isValidJSON(validJson)).toBe(true) + }) + + it('should return false if json is invalid', () => { + const invalidJson = `{ + "a": "b + }` + const invalidJson2 = `{ + 3: 5, + }` + + expect(isValidJSON(invalidJson)).toBe(false) + expect(isValidJSON(invalidJson2)).toBe(false) + }) +}) + +describe('isJSONataExpression', () => { + it.each(['{% expression %}', '{%%}'])('should return true for %s', (expression) => { + expect(isJSONataExpression(expression)).toBe(true) + }) + + it('should return false if not a string', () => { + expect(isJSONataExpression(123)).toBe(false) + expect(isJSONataExpression(null)).toBe(false) + expect(isJSONataExpression({})).toBe(false) + }) + + it('should return false if string does not start with {%', () => { + expect(isJSONataExpression('expression %}')).toBe(false) + }) + + it.each(['%{ expression }%', ' {% expression %}', '{% expression %} '])( + 'should return false if string is %s', + (expression) => { + expect(isJSONataExpression(expression)).toBe(false) + }, + ) + + it('should return false if string does not end with %}', () => { + expect(isJSONataExpression('{% expression')).toBe(false) + }) + + it('should return false if empty string', () => { + expect(isJSONataExpression('')).toBe(false) + }) + + it('should return false if string does not include both starting and ending expression', () => { + expect(isJSONataExpression('{%}')).toBe(false) + }) +}) diff --git a/src/asl-utils/utils/utils.ts b/src/asl-utils/utils/utils.ts new file mode 100644 index 000000000..0d5f1b24f --- /dev/null +++ b/src/asl-utils/utils/utils.ts @@ -0,0 +1,41 @@ +/*! + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +import cloneDeep from 'lodash/cloneDeep' + +/** + * Creates a deep clone of the given object. + * Use this function to convert a frozen state to an immutable one. + * + * See {@link Store} + */ +export function deepClone(obj: T): T { + // structuredClone does not copy functions/symbols, but lodash/cloneDeep does + return cloneDeep(obj) +} + +export function lastItem(arr: any[]): any | undefined { + return arr.length > 0 ? arr[arr.length - 1] : undefined +} + +/** + * The value could be anything from the ASL. We first confirm if its a string and then check if its a JSONata expression + * @param value any value from the definition + * @returns true if the given value is a JSONata expression. + */ +export const isJSONataExpression = (value: unknown): value is `{%${string}%}` => + typeof value === 'string' && value.startsWith('{%') && value.endsWith('%}') && value.length >= 4 + +/** + * Returns true if JSON is valid and false otherwise + */ +export const isValidJSON = (jsonString: string): boolean => { + try { + JSON.parse(jsonString) + return true + } catch (error) { + return false + } +} diff --git a/src/completion/completeAsl.ts b/src/completion/completeAsl.ts index 088b6aab2..ab9eb82dc 100644 --- a/src/completion/completeAsl.ts +++ b/src/completion/completeAsl.ts @@ -4,19 +4,23 @@ */ import { CompletionList, JSONDocument, Position, TextDocument } from 'vscode-json-languageservice' - -import { ASLOptions, ASTTree, findNodeAtLocation } from '../utils/astUtilityFunctions' - +import { buildPreviousStatesMap, Asl, QueryLanguages } from '../asl-utils' +import { ASLOptions, ASTTree, findNodeAtLocation, getStateInfo } from '../utils/astUtilityFunctions' import completeSnippets from './completeSnippets' import completeStateNames from './completeStateNames' +import completeVariables from './completeVariables' +import completeJSONata from './completeJSONata' +import { LANGUAGE_IDS } from '../constants/constants' -export default function completeAsl( +let asl: Asl = {} + +export default async function completeAsl( document: TextDocument, position: Position, doc: JSONDocument, jsonCompletions: CompletionList | null, aslOptions?: ASLOptions, -): CompletionList { +): Promise { const offset = document.offsetAt(position) const rootNode = (doc as ASTTree).root @@ -41,6 +45,37 @@ export default function completeAsl( } } + if (document.languageId === LANGUAGE_IDS.JSON) { + const text = document.getText() + // we are using the last valid asl for autocompletion list generation + // skip to store asl when it is invalid + try { + asl = JSON.parse(text) + } catch (_err) { + // noop + } + + // prepare dynamic variable list + buildPreviousStatesMap(asl) + } + + const { queryLanguage } = (node && getStateInfo(node)) || {} + const isJSONataState = queryLanguage === QueryLanguages.JSONata || asl.QueryLanguage === QueryLanguages.JSONata + + if (isJSONataState) { + const jsonataList = await completeJSONata(node, offset, document, asl) + + if (jsonataList?.items) { + completionList.items = completionList.items.concat(jsonataList.items) + } + } else { + const variableList = completeVariables(node, offset, document, asl) + + if (variableList?.items) { + completionList.items = completionList.items.concat(variableList.items) + } + } + // Assign sort order for the completion items so we maintain order // and snippets are shown near the end of the completion list completionList.items.map((item, index) => ({ ...item, sortText: index.toString() })) diff --git a/src/completion/completeJSONata.ts b/src/completion/completeJSONata.ts new file mode 100644 index 000000000..a4df90009 --- /dev/null +++ b/src/completion/completeJSONata.ts @@ -0,0 +1,283 @@ +/*! + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ +import { + Asl, + VARIABLE_PREFIX, + JSONATA_TEMPLATE_WRAPPER, + getFunctionArguments, + JSONataASTResult, + JsonataFunctionsMap, +} from '../asl-utils' +import { + ASTNode, + CompletionItem, + CompletionItemKind, + CompletionList, + Position, + Range, + TextDocument, + TextEdit, +} from 'vscode-json-languageservice' +import { LANGUAGE_IDS } from '../constants/constants' +import { getStateInfo, isPropertyNode, isStringNode } from '../utils/astUtilityFunctions' +import { getVariableCompletions } from './utils/variableUtils' +import { getJSONataNodeData } from './utils/jsonataUtils' + +interface FunctionCompletionProperties { + jsonataNode: JSONataASTResult + node: ASTNode + asl: Asl + nodePosition: Position + endPosition: Position +} + +/** + * Generates completion list for variables + * @returns + */ +function getVariablesCompletionList({ + node, + value, + asl, + replaceRange, +}: { + node: ASTNode + value: string + asl: Asl + replaceRange: Range +}) { + const variableCompletions = getVariableCompletions(node, value, asl) + return ( + variableCompletions?.varList.map((name) => { + const item = CompletionItem.create(name) + item.commitCharacters = ['.'] + + item.kind = CompletionItemKind.Variable + const prefix = variableCompletions.completionList.parentPath + const completeVal = (prefix ? `${VARIABLE_PREFIX}${prefix}.` : '') + name + + item.textEdit = TextEdit.replace(replaceRange, completeVal) + item.filterText = completeVal + item.label = name + + return item + }) || [] + ) +} + +/** + * Generate completion list for partial paths such as `$states.cont` + * @returns CompletionList if a partial path exists, otherwise undefined + */ +function getPartialPathCompletion({ + jsonataNode, + node, + asl, + nodePosition, + endPosition, +}: FunctionCompletionProperties): CompletionList | undefined { + const isJsonataPathNode = jsonataNode.node.type === 'name' && jsonataNode.node.position && jsonataNode.node.value + + const isParentNodePath = jsonataNode.parent?.type === 'path' + const parentPath = jsonataNode.parent?.steps + + if (isJsonataPathNode && isParentNodePath && parentPath) { + const parentPathKey: string = parentPath + .map((step) => step.value) + .filter((value) => typeof value === 'string' && value) + .join('.') + + const range = Range.create( + Position.create(nodePosition.line, endPosition.character - parentPathKey.length), + endPosition, + ) + return { + isIncomplete: true, + items: getVariablesCompletionList({ + node, + value: parentPathKey, + asl, + replaceRange: range, + }), + } + } +} + +/** + * Generate completion list for incomplete paths such as `$states.` + * @returns CompletionList if an incomplete path exists, otherwise undefined + */ +function getIncompletePathCompletion({ + jsonataNode, + nodePosition, + endPosition, + node, + asl, +}: FunctionCompletionProperties): CompletionList | undefined { + const isErrorNode = jsonataNode.node.type === 'error' && jsonataNode.node.error && jsonataNode.node.error.position + + const isParentNodePath = jsonataNode.parent?.type === 'path' + const parentPath = jsonataNode.parent?.steps + if (isErrorNode && isParentNodePath && parentPath) { + const parentPathKey: string = + parentPath + .map((step) => step.value) + .filter((value) => typeof value === 'string' && value) + .join('.') + '.' + + const range = Range.create( + Position.create(nodePosition.line, endPosition.character - parentPathKey.length), + endPosition, + ) + + return { + isIncomplete: true, + items: getVariablesCompletionList({ + node, + value: parentPathKey, + asl, + replaceRange: range, + }), + } + } +} + +/** + * Generate completion list for functions + * @returns CompletionList if a function exists, otherwise undefined + */ +function getFunctionCompletions({ + jsonataNodeLength, + jsonataFunctions, + properties, +}: { + jsonataNodeLength: number + jsonataFunctions: JsonataFunctionsMap + properties: FunctionCompletionProperties +}): CompletionList | undefined { + const { jsonataNode, node, asl, nodePosition, endPosition } = properties + + const range = Range.create(Position.create(nodePosition.line, endPosition.character - jsonataNodeLength), endPosition) + + const functions = jsonataNode.node.type === 'variable' ? Array.from(jsonataFunctions.keys()) : [] + + const functionCompletionItems = functions.map((name) => { + const item = CompletionItem.create(name) + item.commitCharacters = ['('] + + item.kind = CompletionItemKind.Function + const completeVal = name + const functionProps = jsonataFunctions.get(name) + + item.textEdit = TextEdit.replace(range, completeVal) + item.filterText = completeVal + item.label = name + + item.detail = `${name}(${getFunctionArguments(functionProps?.params || [])})` + + item.documentation = functionProps?.description && { + kind: 'markdown', + value: functionProps?.description, + } + + return item + }) + + const variableCompletionItems = getVariablesCompletionList({ + node, + value: jsonataNode.node.value, + asl, + replaceRange: range, + }).map( + (item) => + ({ + ...item, + // Place variables at the top of the list + sortText: `!${item.label}`, + }) as CompletionItem, + ) + + return { + isIncomplete: false, + items: variableCompletionItems.concat(functionCompletionItems), + } +} + +export default async function completeJSONata( + node: ASTNode | undefined, + offset: number, + document: TextDocument, + asl: Asl, +): Promise { + if (!node || document.languageId === LANGUAGE_IDS.YAML) { + return + } + + if (!node || !node.parent || !isPropertyNode(node.parent)) { + return + } + + const isValueNode = node.parent.valueNode?.offset === node.offset + if (!isStringNode(node) || !isValueNode) { + return + } + + const stateInfo = getStateInfo(node) + // don't generate JSONata autocomplete strings if not inside a state + if (!stateInfo) { + return + } + + const jsonataNodeData = await getJSONataNodeData(document, offset, node) + + if (!jsonataNodeData) { + return + } + + const { jsonataNode, nodePosition, jsonataNodePosition, jsonataFunctions } = jsonataNodeData + + const jsonataNodeLength = jsonataNode.node.value ? String(jsonataNode?.node.value).length : 0 + + const endPosition = Position.create( + nodePosition.line, + jsonataNodePosition + JSONATA_TEMPLATE_WRAPPER.start.length + nodePosition.character, + ) + + const partialPathCompletion = getPartialPathCompletion({ + jsonataNode, + nodePosition, + endPosition, + node, + asl, + }) + if (partialPathCompletion) { + return partialPathCompletion + } + + const incompletePathCompletion = getIncompletePathCompletion({ + jsonataNode, + nodePosition, + endPosition, + node, + asl, + }) + if (incompletePathCompletion) { + return incompletePathCompletion + } + + const variableAndFunctionCompletions = getFunctionCompletions({ + jsonataNodeLength, + jsonataFunctions, + properties: { + jsonataNode, + nodePosition, + endPosition, + node, + asl, + }, + }) + + return variableAndFunctionCompletions +} diff --git a/src/completion/completeVariables.ts b/src/completion/completeVariables.ts new file mode 100644 index 000000000..0bd4b8963 --- /dev/null +++ b/src/completion/completeVariables.ts @@ -0,0 +1,122 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ +import { Asl, VARIABLE_PREFIX } from '../asl-utils' +import { + ASTNode, + CompletionItem, + CompletionItemKind, + CompletionList, + Range, + TextDocument, + TextEdit, +} from 'vscode-json-languageservice' +import { LANGUAGE_IDS } from '../constants/constants' +import { CompleteStateNameOptions, isPropertyNode, isStringNode } from '../utils/astUtilityFunctions' +import { getVariableCompletions } from './utils/variableUtils' + +function getCompletionList( + prefix: string, + items: string[], + replaceRange: Range, + languageId: string, + options: CompleteStateNameOptions, +) { + const { shouldAddLeftQuote, shouldAddRightQuote, shouldAddLeadingSpace, shoudlAddTrailingComma } = options + + const list: CompletionList = { + isIncomplete: false, + items: items.map((name) => { + const item = CompletionItem.create(name) + item.commitCharacters = [','] + + item.kind = CompletionItemKind.Variable + const completeVal = (prefix ? `${VARIABLE_PREFIX}${prefix}.` : '') + name + + const newText = + (shouldAddLeadingSpace ? ' ' : '') + + (shouldAddLeftQuote ? '"' : '') + + completeVal + + (shouldAddRightQuote ? '"' : '') + + (shoudlAddTrailingComma ? ',' : '') + item.textEdit = TextEdit.replace(replaceRange, newText) + item.filterText = completeVal + item.label = name + + return item + }), + } + + return list +} + +export default function completeVariables( + node: ASTNode | undefined, + offset: number, + document: TextDocument, + asl: Asl, +): CompletionList | undefined { + if (!node || document.languageId === LANGUAGE_IDS.YAML) { + return + } + + const variableCompletions = getVariableCompletions(node, node.value?.toString(), asl) + if (!variableCompletions) { + return + } + + const { varList, completionList } = variableCompletions + + if (isPropertyNode(node) && node.colonOffset) { + if (varList.length) { + const colonPosition = document.positionAt(node.colonOffset + 1) + let endPosition = document.positionAt(node.offset + node.length) + + // The range shouldn't span multiple lines, if lines are different it is due to + // lack of comma and text should be inserted in place + if (colonPosition.line !== endPosition.line) { + endPosition = colonPosition + } + + const range = Range.create(colonPosition, endPosition) + + const completeOptions = { + shouldAddLeftQuote: true, + shouldAddRightQuote: true, + shouldAddLeadingSpace: true, + shoudlAddTrailingComma: false, + } + + return getCompletionList(completionList.parentPath, varList, range, document.languageId, completeOptions) + } + } + + // For string nodes that have a parent that is a property node + if (node && node.parent && isPropertyNode(node.parent)) { + const isValueNode = node.parent.valueNode?.offset === node.offset + if (isStringNode(node) && isValueNode) { + if (varList.length) { + // Text edit will only work when start position is higher than the node offset + const startPosition = document.positionAt(node.offset + 1) + const endPosition = document.positionAt(node.offset + node.length - 1) + + const range = Range.create(startPosition, endPosition) + + const completeStateNameOptions = { + shouldAddLeftQuote: false, + shouldAddRightQuote: false, + shouldAddLeadingSpace: false, + shoudlAddTrailingComma: false, + } + return getCompletionList( + completionList.parentPath, + varList, + range, + document.languageId, + completeStateNameOptions, + ) + } + } + } +} diff --git a/src/completion/utils/jsonataUtils.ts b/src/completion/utils/jsonataUtils.ts new file mode 100644 index 000000000..4ac4513c5 --- /dev/null +++ b/src/completion/utils/jsonataUtils.ts @@ -0,0 +1,68 @@ +/*! + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ +import { + JSONATA_TEMPLATE_WRAPPER, + JsonataFunctionsMap, + ExprNode, + getJSONataAST, + getJSONataFunctionList, + findNodeInJSONataAST, + JSONataASTResult, +} from '../../asl-utils' +import { ASTNode, Position, TextDocument } from 'vscode-json-languageservice' + +export async function getJSONataNodeData( + document: TextDocument, + offset: number, + node: ASTNode, +): Promise< + | { + jsonataNode: JSONataASTResult + nodePosition: Position + jsonataNodePosition: number + jsonataFunctions: JsonataFunctionsMap + } + | undefined +> { + const { start: JSONATA_PREFIX, end: JSONATA_SUFFIX } = JSONATA_TEMPLATE_WRAPPER + + const nodeValue = node.value?.toString() + + if (!nodeValue || !nodeValue.startsWith(JSONATA_PREFIX) || !nodeValue.endsWith(JSONATA_SUFFIX)) { + return + } + + const cursorPosition = document.positionAt(offset) + const nodePosition = document.positionAt(node.offset) + + const positionInString = cursorPosition.character - nodePosition.character - JSONATA_PREFIX.length - 1 + + let jsonataFunctions: JsonataFunctionsMap + let jsonataAst: ExprNode + try { + const jsonataStringCursorPosition = positionInString + JSONATA_PREFIX.length + + ;[jsonataAst, jsonataFunctions] = await Promise.all([ + getJSONataAST(nodeValue.slice(JSONATA_PREFIX.length, jsonataStringCursorPosition)), + getJSONataFunctionList(), + ]) + } catch (_err) { + return + } + + const jsonataNode = findNodeInJSONataAST(jsonataAst, positionInString) + const jsonataNodePosition = jsonataNode?.node.position || jsonataNode?.node.error?.position + + if (!jsonataNode || !jsonataNodePosition) { + return + } + + return { + jsonataNode, + nodePosition, + jsonataNodePosition, + jsonataFunctions, + } +} diff --git a/src/completion/utils/variableUtils.ts b/src/completion/utils/variableUtils.ts new file mode 100644 index 000000000..35f162e3f --- /dev/null +++ b/src/completion/utils/variableUtils.ts @@ -0,0 +1,52 @@ +/*! + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ +import { Asl, MonacoCompletionsResult, getAssignCompletionList, getCompletionStrings, StateType } from '../../asl-utils' +import { ASTNode } from 'vscode-json-languageservice' +import { getStateInfo, findClosestAncestorNodeByName } from '../../utils/astUtilityFunctions' + +const FIELDS_INPUT = ['Parameters', 'InputPath', 'Arguments'] +const FIELDS_SUCCESS = ['Output', 'Assign', 'ResultSelector', 'OutputPath'] +const FIELDS_FAIL = ['Catch'] +const FIELDS_MAP = ['ItemSelector'] + +const FIELDS_WITH_TEMPLATE = [...FIELDS_INPUT, ...FIELDS_SUCCESS, ...FIELDS_FAIL, ...FIELDS_MAP] + +export function getVariableCompletions( + node: ASTNode, + value: string | undefined, + asl: Asl, +): + | { + varList: string[] + completionList: MonacoCompletionsResult + } + | undefined { + const { stateName, stateType } = getStateInfo(node) || {} + // cannot generate a list of available varialbes if not inside a state + if (!stateName) { + return + } + + const supportedAncestorNode = findClosestAncestorNodeByName(node, FIELDS_WITH_TEMPLATE) + const isError = !!findClosestAncestorNodeByName(node, FIELDS_FAIL) + if (!supportedAncestorNode) { + return + } + + const availableVariables = getAssignCompletionList(asl, stateName, stateName) + + const completionList = getCompletionStrings({ + nodeVal: value || '', + completionScope: availableVariables, + reservedVariablesParams: { + isError: isError, + isSuccess: !isError && FIELDS_SUCCESS.includes(supportedAncestorNode.nodeName), + isItemSelector: FIELDS_MAP.includes(supportedAncestorNode.nodeName), + stateType: (stateType as StateType) || 'Task', // default to task node input if state type is undetermined + }, + }) + const varList: string[] = completionList.items + return { varList, completionList } +} diff --git a/src/service.ts b/src/service.ts index 13948ea73..8558568b3 100644 --- a/src/service.ts +++ b/src/service.ts @@ -23,7 +23,7 @@ import { getLanguageService as getAslYamlLanguageService } from './yaml/aslYamlL export * from 'vscode-json-languageservice' -interface ASLLanguageServiceParams extends LanguageServiceParams { +export interface ASLLanguageServiceParams extends LanguageServiceParams { aslOptions?: ASLOptions } @@ -88,6 +88,5 @@ export const getYamlLanguageService = function (params: ASLLanguageServiceParams ignoreColonOffset: true, }, }) - return getAslYamlLanguageService(params, ASL_SCHEMA, aslLanguageService) } diff --git a/src/tests/aslUtilityFunctions.test.ts b/src/tests/aslUtilityFunctions.test.ts index 2910e7631..5a03d2310 100644 --- a/src/tests/aslUtilityFunctions.test.ts +++ b/src/tests/aslUtilityFunctions.test.ts @@ -13,7 +13,11 @@ import { getListOfStateNamesFromStateNode, insideStateNode, isChildOfStates, + getStateInfo, + findClosestAncestorNodeByName, } from '../utils/astUtilityFunctions' +import { documentWithAssignAndCatch } from './json-strings/variableStrings' +import { QueryLanguages } from '../asl-utils' const document = ` { @@ -55,6 +59,31 @@ const document = ` } ` +const documentJSONata = ` +{ + "Comment": "A comment", + "QueryLanguage": "JSONata", + "States": { + "FirstState": {} + } + } +` + +const documentJSONPath = ` +{ + "Comment": "A comment", + "QueryLanguage": "JSONPath", + "States": { + "FirstState": { + "QueryLanguage": "JSONata" + }, + "SecondState": { + "QueryLanguage": "JSONPath" + } + } + } +` + // Invalid state name property const documentInvalid = ` { @@ -70,7 +99,6 @@ function toDocument(text: string): { textDoc: TextDocument; jsonDoc: ASTTree } { const textDoc = TextDocument.create('foo://bar/file.asl', 'json', 0, text) const ls = getLanguageService({}) - // tslint:disable-next-line: no-inferred-empty-object-type const jsonDoc = ls.parseJSONDocument(textDoc) as ASTTree return { textDoc, jsonDoc } @@ -79,7 +107,7 @@ function toDocument(text: string): { textDoc: TextDocument; jsonDoc: ASTTree } { describe('Utility functions for extracting data from AST Tree', () => { test('getListOfStateNamesFromStateNode - retrieves list of states from state node', async () => { const { jsonDoc } = toDocument(document) - const stateNode = jsonDoc.root!.children![1] as PropertyASTNode + const stateNode = jsonDoc.root?.children?.[1] as PropertyASTNode const stateNames = getListOfStateNamesFromStateNode(stateNode) const expectedStateNames = [ @@ -98,14 +126,14 @@ describe('Utility functions for extracting data from AST Tree', () => { test('getListOfStateNamesFromStateNode - throws an error when property named "States" is not provided', async () => { const { jsonDoc } = toDocument(document) - const stateNode = jsonDoc.root!.children![0] as PropertyASTNode + const stateNode = jsonDoc.root?.children?.[0] as PropertyASTNode assert.throws(() => getListOfStateNamesFromStateNode(stateNode), { message: 'Not a state name property node' }) }) test('getListOfStateNamesFromStateNode - retrieves only valid states', () => { const { jsonDoc } = toDocument(documentInvalid) - const stateNode = jsonDoc.root!.children![0] as PropertyASTNode + const stateNode = jsonDoc.root?.children?.[0] as PropertyASTNode const stateNames = getListOfStateNamesFromStateNode(stateNode) const expectedStateNames = ['FirstState', 'SecondState'] @@ -118,11 +146,11 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('MapState2') + 1 - const node = findNodeAtLocation(jsonDoc.root!, location) + const node = findNodeAtLocation(jsonDoc.root, location) assert.ok(!!node) - const nodeText = document.slice(node!.offset, node!.offset + node!.length) + const nodeText = document.slice(node?.offset, node?.offset + node?.length) assert.strictEqual(nodeText, '"MapState2"') }) @@ -131,15 +159,16 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('State4') + 1 - const node = findNodeAtLocation(jsonDoc.root!, location) - const statesNode = findClosestAncestorStateNode(node!) + const node = findNodeAtLocation(jsonDoc.root, location) + assert(!!node) + const statesNode = findClosestAncestorStateNode(node) assert(!!statesNode) - const nodeText = statesNode!.keyNode.value + const nodeText = statesNode?.keyNode.value assert.strictEqual(nodeText, 'States') - const stateNames = getListOfStateNamesFromStateNode(statesNode!) + const stateNames = getListOfStateNamesFromStateNode(statesNode) assert.deepEqual(stateNames, ['State1', 'State2', 'State3', 'State4']) }) @@ -148,7 +177,7 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('MapState1') - 2 - const node = findNodeAtLocation(jsonDoc.root!, location) + const node = findNodeAtLocation(jsonDoc.root, location) assert.strictEqual(isChildOfStates(node as ASTNode), true) }) @@ -156,7 +185,7 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('SecondMatchState') - const node = findNodeAtLocation(jsonDoc.root!, location) + const node = findNodeAtLocation(jsonDoc.root, location) assert.strictEqual(isChildOfStates(node as ASTNode), false) }) @@ -164,7 +193,7 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('SecondMatchState') + 1 - const node = findNodeAtLocation(jsonDoc.root!, location) + const node = findNodeAtLocation(jsonDoc.root, location) assert.strictEqual(insideStateNode(node as ASTNode), true) }) @@ -172,7 +201,93 @@ describe('Utility functions for extracting data from AST Tree', () => { const { jsonDoc } = toDocument(document) const location = document.indexOf('MapState1') - 2 - const node = findNodeAtLocation(jsonDoc.root!, location) + const node = findNodeAtLocation(jsonDoc.root, location) assert.strictEqual(insideStateNode(node as ASTNode), false) }) + + test('getStateInfo - should return name and type inside a map state', () => { + const { jsonDoc } = toDocument(document) + const location = document.indexOf('MapState1') + 2 + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = getStateInfo(node) + + assert.equal(result?.stateName, 'MapState1') + assert.equal(result?.stateType, 'Map') + assert.equal(result?.queryLanguage, undefined) + }) + + test('getStateInfo - should return name and type inside a task state', () => { + const { jsonDoc } = toDocument(document) + const location = document.indexOf('State3') + 2 + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = getStateInfo(node) + + assert.equal(result?.stateName, 'State3') + assert.equal(result?.stateType, 'Task') + assert.equal(result?.queryLanguage, undefined) + }) + + test('getStateInfo - should return undefined queryLanguage if not set', () => { + const { jsonDoc } = toDocument(documentJSONata) + const location = documentJSONata.indexOf('FirstState') + 2 + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = getStateInfo(node) + + assert.equal(result?.stateName, 'FirstState') + assert.equal(result?.queryLanguage, undefined) + }) + + test('getStateInfo - should return JSONata query language of node', () => { + const { jsonDoc } = toDocument(documentJSONPath) + const location = documentJSONPath.indexOf('FirstState') + 2 + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = getStateInfo(node) + + assert.equal(result?.stateName, 'FirstState') + assert.equal(result?.queryLanguage, QueryLanguages.JSONata) + }) + + test('findClosestAncestorNodeByName - should return Assign ancestor node', () => { + const { jsonDoc } = toDocument(documentWithAssignAndCatch) + const location = documentWithAssignAndCatch.indexOf('var_lambda2') + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = findClosestAncestorNodeByName(node, ['Assign', 'Parameters']) + + assert(result?.node) + assert.equal(result?.nodeName, 'Assign') + }) + + test('findClosestAncestorNodeByName - should return Catch ancestor node', () => { + const { jsonDoc } = toDocument(documentWithAssignAndCatch) + const location = documentWithAssignAndCatch.indexOf('error') + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = findClosestAncestorNodeByName(node, ['Catch', 'Parameters']) + + assert(result?.node) + assert.equal(result?.nodeName, 'Catch') + }) + + test('findClosestAncestorNodeByName - should return innermost ancestor node', () => { + const { jsonDoc } = toDocument(documentWithAssignAndCatch) + const location = documentWithAssignAndCatch.indexOf('error') + + const node = findNodeAtLocation(jsonDoc.root, location) + assert(node) + const result = findClosestAncestorNodeByName(node, ['Catch', 'Assign']) + + assert(result?.node) + assert.equal(result?.nodeName, 'Assign') + }) }) diff --git a/src/tests/completion.test.ts b/src/tests/completion.test.ts index 79f38451f..46a8b820e 100644 --- a/src/tests/completion.test.ts +++ b/src/tests/completion.test.ts @@ -3,14 +3,15 @@ * SPDX-License-Identifier: MIT */ -// tslint:disable:no-floating-promises - import * as assert from 'assert' import { CompletionItemKind } from 'vscode-json-languageservice' import { errorHandlingSnippets, stateSnippets } from '../completion/completeSnippets' import { getLanguageService, Position, Range } from '../service' import { asTextEdit, toDocument } from './utils/testUtilities' - +import { documentWithAssignAndCatch, documentWithAssignAndCatchInvalidJson } from './json-strings/variableStrings' +import { ASLOptions } from '../utils/astUtilityFunctions' +import { globalJsonataDocument, stateLevelJsonataDocument } from './json-strings/jsonataStrings' +import { getJSONataFunctionList } from '../asl-utils' import { document1, document2, @@ -32,6 +33,7 @@ interface TestCompletionOptions { start: [number, number] end: [number, number] labelToInsertText(label: string): string + aslOptions?: ASLOptions } interface TestScenario { @@ -41,10 +43,10 @@ interface TestScenario { end: [number, number] } -async function getCompletions(json: string, position: [number, number]) { +async function getCompletions(json: string, position: [number, number], aslOptions?: ASLOptions) { const { textDoc, jsonDoc } = toDocument(json) const pos = Position.create(...position) - const ls = getLanguageService({}) + const ls = getLanguageService({ aslOptions }) return await ls.doComplete(textDoc, pos, jsonDoc) } @@ -52,8 +54,7 @@ async function getCompletions(json: string, position: [number, number]) { async function testCompletions(options: TestCompletionOptions) { const { labels, json, position, start, end, labelToInsertText } = options - const res = await getCompletions(json, position) - + const res = await getCompletions(json, position, options.aslOptions) assert.strictEqual(res?.items.length, labels.length) // space before quoted item @@ -86,7 +87,7 @@ function getArrayIntersection(arrayOne: string[] | undefined, arrayTwo: string[] } async function getSuggestedSnippets(options: TestScenario) { - const { json, position, start, end } = options + const { json, position } = options const { textDoc, jsonDoc } = toDocument(json) const pos = Position.create(...position) @@ -335,4 +336,230 @@ describe('ASL context-aware completion', () => { await assert.doesNotReject(getCompletions(completionsEdgeCase2, [4, 7]), TypeError) }) }) + + describe('Variable', () => { + test('Suggest only context variable when JSON is invalid', async () => { + await testCompletions({ + labels: ['$states'], + json: documentWithAssignAndCatchInvalidJson, + position: [24, 20], + start: [24, 20], + end: [24, 20], + labelToInsertText: (label) => ` "${label}"`, + }) + }) + + test('Suggest variable after colon from latest valid json', async () => { + const position: [number, number] = [24, 20] + const labels = ['$var_lambda1', '$states'] + + const { textDoc, jsonDoc } = toDocument(documentWithAssignAndCatch) + const pos = Position.create(...position) + const ls = getLanguageService({ aslOptions: {} }) + // setup dynamic variable list with a valid JSON + await ls.doComplete(textDoc, pos, jsonDoc) + + // invalid json will generate available variable list from latest valid json + const { textDoc: invalidTextDoc, jsonDoc: invalidJsonDoc } = toDocument(documentWithAssignAndCatchInvalidJson) + const res = await ls.doComplete(invalidTextDoc, pos, invalidJsonDoc) + + const itemInsertTexts = labels.map((label) => ` "${label}"`) + assert.deepEqual( + res?.items.map((item) => item.label), + labels, + ) + assert.deepEqual( + res?.items.map((item) => item.textEdit?.newText), + itemInsertTexts, + ) + }) + + test('Show variable with cursor between quotation marks', async () => { + await testCompletions({ + labels: ['$var_lambda1', '$states'], + json: documentWithAssignAndCatch, + position: [23, 24], + start: [23, 24], + end: [23, 24], + labelToInsertText: (label) => `${label}`, + }) + }) + + test('Suggest input reserved variable when cursor after $states variable in parameter field', async () => { + await testCompletions({ + labels: ['input', 'context'], + json: documentWithAssignAndCatch, + position: [19, 22], + start: [19, 22], + end: [19, 30], + labelToInsertText: (label) => `$states.${label}`, + }) + }) + + test('Suggest error reserved variable when cursor after $states variable in catcher block', async () => { + await testCompletions({ + labels: ['input', 'context', 'errorOutput'], + json: documentWithAssignAndCatch, + position: [29, 22], + start: [29, 22], + end: [29, 30], + labelToInsertText: (label) => `$states.${label}`, + }) + }) + }) + + describe('JSONata', () => { + test('Suggest JSONata functions and variables when at the correct position with global JSONata is enabled', async () => { + const functions = await getJSONataFunctionList() + const functionLabels = Array.from(functions.keys()) + const variableLabels = ['$var_lambda1', '$states'] + await testCompletions({ + labels: variableLabels.concat(functionLabels), + json: globalJsonataDocument, + // Lambda2.Arguments.Payload + position: [18, 24], + start: [18, 23], + end: [18, 23], + labelToInsertText: (label) => label, + }) + }) + + test('Suggests JSONata functions and variables when at the correct position with global JSONata and variables is disabled', async () => { + const functions = await getJSONataFunctionList() + const functionLabels = Array.from(functions.keys()) + const variableLabels = ['$var_lambda1', '$states'] + await testCompletions({ + labels: variableLabels.concat(functionLabels), + json: globalJsonataDocument, + // Lambda2.Arguments.Payload + position: [18, 24], + start: [18, 23], + end: [18, 23], + labelToInsertText: (label) => label, + }) + }) + + test('Suggest JSONata functions and variables when at the correct position with state level JSONata', async () => { + const functions = await getJSONataFunctionList() + const functionLabels = Array.from(functions.keys()) + const variableLabels = ['$var_lambda1', '$states'] + await testCompletions({ + labels: variableLabels.concat(functionLabels), + json: stateLevelJsonataDocument, + // Lambda2.Arguments.Payload + position: [22, 24], + start: [22, 23], + end: [22, 23], + labelToInsertText: (label) => label, + }) + }) + + test('Does not suggest as JSONata when not in a JSONata state', async () => { + await testCompletions({ + labels: ['$states'], + json: stateLevelJsonataDocument, + // Lambda1.Parameters.Payload + position: [11, 24], + start: [11, 20], + end: [11, 27], + labelToInsertText: (label) => label, + }) + }) + + test('Does not suggest JSONata functions and variables when string is not a valid JSONata expression', async () => { + await testCompletions({ + labels: [], + json: globalJsonataDocument, + // Lambda2.Arguments.FunctionName + position: [19, 37], + start: [19, 37], + end: [19, 37], + labelToInsertText: (label) => label, + }) + }) + + test('Does not suggest JSONata functions and variables when cursor is not over a string node', async () => { + await testCompletions({ + labels: [], + json: globalJsonataDocument, + // Lambda2.Arguments.Payload + position: [19, 1], + start: [-1, -1], + end: [-1, -1], + labelToInsertText: (label) => label, + }) + }) + + test('Suggest variable completions for one level of incomplete paths', async () => { + await testCompletions({ + labels: ['input', 'context', 'result'], + json: globalJsonataDocument, + // Lambda2.Assign.var_lambda2 + position: [22, 35], + start: [22, 27], + end: [22, 34], + labelToInsertText: (label) => `$states.${label}`, + }) + }) + + test('Suggest variable completions for multi-level incomplete paths', async () => { + await testCompletions({ + labels: ['Execution', 'State', 'StateMachine', 'Task'], + json: globalJsonataDocument, + // Lambda2.Assign.var_lambda3 + position: [23, 43], + start: [23, 27], + end: [23, 42], + labelToInsertText: (label) => `$states.context.${label}`, + }) + }) + + test('Suggest variable completions for one level partial paths', async () => { + await testCompletions({ + labels: ['input', 'context', 'result'], + json: globalJsonataDocument, + // Lambda2.Assign.var_lambda4 + position: [24, 39], + start: [24, 27], + end: [24, 38], + labelToInsertText: (label) => `$states.${label}`, + }) + }) + + test('Suggest variable completions for multi-level partial paths', async () => { + await testCompletions({ + labels: ['Execution', 'State', 'StateMachine', 'Task'], + json: globalJsonataDocument, + // Lambda2.Assign.var_lambda5 + position: [25, 44], + start: [25, 27], + end: [25, 43], + labelToInsertText: (label) => `$states.context.${label}`, + }) + }) + + test('Does not suggest if JSONata is invalid', async () => { + await testCompletions({ + labels: [], + json: globalJsonataDocument, + // Lambda2.Assign.var_lambda6 + position: [26, 32], + start: [-1, -1], + end: [-1, -1], + labelToInsertText: (label) => label, + }) + }) + + test('Does not suggest if not inside a state', async () => { + await testCompletions({ + labels: [], + json: globalJsonataDocument, + // Comment field + position: [2, 17], + start: [-1, -1], + end: [-1, -1], + labelToInsertText: (label) => label, + }) + }) + }) }) diff --git a/src/tests/json-strings/completionStrings.ts b/src/tests/json-strings/completionStrings.ts index 360027f1f..28aeb0d76 100644 --- a/src/tests/json-strings/completionStrings.ts +++ b/src/tests/json-strings/completionStrings.ts @@ -120,7 +120,7 @@ export const document6 = ` "StartAt": "First", "States": { "FirstState": { - "Type": "Task" + "Type": "Task", }, "ChoiceState": {}, "FirstMatchState": {}, diff --git a/src/tests/json-strings/jsonataStrings.ts b/src/tests/json-strings/jsonataStrings.ts new file mode 100644 index 000000000..fa8324abe --- /dev/null +++ b/src/tests/json-strings/jsonataStrings.ts @@ -0,0 +1,67 @@ +/*! + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +export const globalJsonataDocument = ` +{ + "Comment": "{% $ %}", + "StartAt": "Lambda1", + "QueryLanguage": "JSONata", + "States": { + "Lambda1": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Next": "Lambda2", + "Assign": { + "var_lambda1": 123 + } + }, + "Lambda2": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "Payload": "{% $ %}", + "FunctionName": "{% myFunction" + }, + "Assign": { + "var_lambda2": "{% $states. %}", + "var_lambda3": "{% $states.context. %}", + "var_lambda4": "{% $states.cont %}", + "var_lambda5": "{% $states.context.Ex %}", + "var_lambda6": "{% ,.$/$ %}" + } + } + } +} +` + +export const stateLevelJsonataDocument = ` +{ + "Comment": "A description of my state machine", + "StartAt": "Lambda1", + "QueryLanguage": "JSONPath", + "States": { + "Lambda1": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Next": "Lambda2", + "Parameters": { + "Payload": "{% $ %}" + }, + "Assign": { + "var_lambda1": 123 + } + }, + "Lambda2": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "Payload": "{% $ %}" + }, + "End": true + } + } +} +` diff --git a/src/tests/json-strings/variableStrings.ts b/src/tests/json-strings/variableStrings.ts new file mode 100644 index 000000000..06c2addaf --- /dev/null +++ b/src/tests/json-strings/variableStrings.ts @@ -0,0 +1,92 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: MIT + */ + +export const documentWithAssignAndCatch = ` +{ + "Comment": "A description of my state machine", + "StartAt": "Lambda1", + "States": { + "Lambda1": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Next": "Lambda2", + "Assign": { + "var_lambda1": 123 + } + }, + "Lambda2": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Parameters": { + "Payload.$": "$states." + }, + "End": true, + "Assign": { + "var_lambda2": "" + }, + "Catch": [ + { + "ErrorEquals": [], + "Assign": { + "error": "$states." + } + } + ] + } + } +} +` + +export const documentWithAssignAndCatchInvalidJson = ` +{ + "Comment": "A description of my state machine", + "StartAt": "Lambda1", + "States": { + "Lambda1": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Next": "Lambda2", + "Assign": { + "var_lambda1": 123 + } + }, + "Lambda2": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "OutputPath": "$.Payload", + "Parameters": { + "Payload.$": "$states." + }, + "End": true, + "Assign": { + "var_lambda2": "", + "var_colon": + }, + "Catch": [ + { + "ErrorEquals": [], + "Assign": { + "error": "$states." + } + } + ] + } + } +} +` + +export const documentInvalidFailWithAssign = `{ + "Comment": "An example of the Amazon States Language using a Fail state with Assign field", + "StartAt": "HelloWorld", + "States": { + "HelloWorld": { + "Type": "Fail", + "Assign": {} + } + } +}` diff --git a/src/tests/jsonSchemaAsl.test.ts b/src/tests/jsonSchemaAsl.test.ts index cdeb0635e..271813dc8 100644 --- a/src/tests/jsonSchemaAsl.test.ts +++ b/src/tests/jsonSchemaAsl.test.ts @@ -10,7 +10,6 @@ function toDocument(text: string): { textDoc: TextDocument; jsonDoc: JSONDocumen const textDoc = TextDocument.create('foo://bar/file.asl', 'json', 0, text) const ls = getLanguageService({}) - // tslint:disable-next-line: no-inferred-empty-object-type const jsonDoc = ls.parseJSONDocument(textDoc) as JSONDocument return { textDoc, jsonDoc } diff --git a/src/tests/validation.test.ts b/src/tests/validation.test.ts index 2d7a35786..c29a09d18 100644 --- a/src/tests/validation.test.ts +++ b/src/tests/validation.test.ts @@ -6,17 +6,16 @@ import * as assert from 'assert' import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver' import { MESSAGES } from '../constants/diagnosticStrings' -import { getLanguageService, Position, Range } from '../service' - +import { ASLLanguageServiceParams, getLanguageService, Position, Range } from '../service' import { documentChoiceDefaultBeforeChoice, documentChoiceInvalidDefault, documentChoiceInvalidNext, + documentChoiceWaitJSONata, documentChoiceNextBeforeChoice, documentChoiceNoDefault, documentChoiceValidDefault, documentChoiceValidNext, - documentChoiceWaitJSONata, documentChoiceWithAssign, documentDistributedMapInvalidNextInNestedState, documentFailCauseAndCausePathInvalid, @@ -85,7 +84,6 @@ import { documentValidResultSelectorIntrinsicFunction, documentValidResultSelectorJsonPath, } from './json-strings/validationStrings' - import { toDocument } from './utils/testUtilities' const JSON_SCHEMA_MULTIPLE_SCHEMAS_MSG = 'Matches multiple schemas when only one must validate.' @@ -101,17 +99,17 @@ export interface TestValidationOptions { filterMessages?: string[] } -async function getValidations(json: string) { +async function getValidations(json: string, params: ASLLanguageServiceParams = {}) { const { textDoc, jsonDoc } = toDocument(json) - const ls = getLanguageService({}) + const ls = getLanguageService(params) return await ls.doValidation(textDoc, jsonDoc) } -async function testValidations(options: TestValidationOptions) { +async function testValidations(options: TestValidationOptions, params: ASLLanguageServiceParams = {}) { const { json, diagnostics, filterMessages } = options - let res = await getValidations(json) + let res = await getValidations(json, params) res = res.filter((diagnostic) => { if (filterMessages && filterMessages.find((message) => message === diagnostic.message)) { @@ -1125,7 +1123,6 @@ describe('ASL context-aware validation', () => { }) }) }) - describe('Assign property', () => { test('Should be valid for all state types', async () => { const testCases = [ @@ -1144,7 +1141,6 @@ describe('ASL context-aware validation', () => { }) test('Should be valid in choice state when it is added at top level', async () => { - /* tslint:disable:no-unsafe-any */ const asl = JSON.parse(documentChoiceWithAssign) asl.States.Choice.Assign = {} await testValidations({ @@ -1177,7 +1173,6 @@ describe('ASL context-aware validation', () => { }) test('Should be valid if value is undefined', async () => { - /* tslint:disable:no-unsafe-any */ const asl = JSON.parse(documentTaskWithAssign) asl.States.HelloWorld.Assign = undefined await testValidations({ @@ -1188,10 +1183,8 @@ describe('ASL context-aware validation', () => { test('Should be invalid for all non-object types', async () => { const errorMessage = 'Incorrect type. Expected "object".' - /* tslint:disable:no-null-keyword */ const assignCases = [null, 'NO', 1234, true] for (const assignCase of assignCases) { - /* tslint:disable:no-unsafe-any */ const asl = JSON.parse(documentTaskWithAssign) asl.States.HelloWorld.Assign = assignCase await testValidations({ @@ -1206,7 +1199,6 @@ describe('ASL context-aware validation', () => { }) } - /* tslint:disable:no-unsafe-any */ const aslWithArrayForAssign = JSON.parse(documentTaskWithAssign) aslWithArrayForAssign.States.HelloWorld.Assign = [''] await testValidations({ diff --git a/src/utils/astUtilityFunctions.ts b/src/utils/astUtilityFunctions.ts index 366c6d937..b67eed922 100644 --- a/src/utils/astUtilityFunctions.ts +++ b/src/utils/astUtilityFunctions.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: MIT */ +import { QueryLanguages } from '../asl-utils' import { ArrayASTNode, ASTNode, JSONDocument, - LanguageServiceParams, ObjectASTNode, PropertyASTNode, StringASTNode, @@ -102,8 +102,7 @@ export function findClosestAncestorStateNode(node: ASTNode): PropertyASTNode | u } else if (!node.parent) { return undefined } - - return findClosestAncestorStateNode(node.parent!) + return findClosestAncestorStateNode(node.parent) } /** Extracts the list of state names from given property node named "States" */ @@ -129,3 +128,54 @@ export function getListOfStateNamesFromStateNode(node: PropertyASTNode, ignoreCo throw new Error('Not a state name property node') } } + +interface AncestorASTNodeInfo { + node: PropertyASTNode + nodeName: string +} +/** Finds the closest ancestor property given node names */ +export function findClosestAncestorNodeByName(node: ASTNode, NodeName: string[]): AncestorASTNodeInfo | undefined { + if (isPropertyNode(node) && NodeName.includes(node.keyNode.value)) { + return { node, nodeName: node.keyNode.value } + } + if (!node.parent) { + return undefined + } + + return findClosestAncestorNodeByName(node.parent, NodeName) +} + +/** + * Get the state name, type, and query language of current node + */ +export function getStateInfo( + node: ASTNode, +): { stateName: string; stateType: string | undefined; queryLanguage: QueryLanguages | undefined } | undefined { + const parent = node.parent + if (isPropertyNode(node) && parent && isChildOfStates(node.parent)) { + let stateType: string | undefined + let queryLanguage: QueryLanguages | undefined = undefined + if (node.valueNode && isObjectNode(node.valueNode)) { + const typeProperty = node.valueNode.properties.find((property) => property.keyNode.value === 'Type') + const queryLanguageValue = node.valueNode.properties + .find((property) => property.keyNode.value === 'QueryLanguage') + ?.valueNode?.value?.toString() + stateType = typeProperty?.valueNode?.value?.toString() ?? undefined + if (queryLanguageValue === QueryLanguages.JSONata) { + queryLanguage = QueryLanguages.JSONata + } else if (queryLanguageValue === QueryLanguages.JSONPath) { + queryLanguage = QueryLanguages.JSONPath + } + } + + return { + stateName: node.keyNode.value, + stateType, + queryLanguage, + } + } else if (!parent) { + return undefined + } + + return getStateInfo(node.parent) +} diff --git a/src/validation/utils/getDiagnosticsForNode.ts b/src/validation/utils/getDiagnosticsForNode.ts index bbd9a93ae..00771a2f3 100644 --- a/src/validation/utils/getDiagnosticsForNode.ts +++ b/src/validation/utils/getDiagnosticsForNode.ts @@ -25,7 +25,7 @@ interface SchemaObject { [property: string]: SchemaObject | boolean | string } -function isObject(obj: any): obj is object { +function isObject(obj: unknown): obj is object { return obj === Object(obj) } diff --git a/src/validation/validateProperties.ts b/src/validation/validateProperties.ts index 11c5be63d..1d2f6d590 100644 --- a/src/validation/validateProperties.ts +++ b/src/validation/validateProperties.ts @@ -17,9 +17,7 @@ export default function (oneStateValueNode: ObjectASTNode, document: TextDocumen const diagnostics: Diagnostic[] = [] if (typeof stateType === 'string') { - // tslint:disable-next-line no-unsafe-any const hasCommonProperties = !!schema.StateTypes[stateType]?.hasCommonProperties - // tslint:disable-next-line no-unsafe-any const stateProperties = schema.StateTypes[stateType]?.Properties if (!stateProperties) { @@ -28,7 +26,6 @@ export default function (oneStateValueNode: ObjectASTNode, document: TextDocumen const allowedProperties = hasCommonProperties ? { ...schema.Common, ...stateProperties } : { ...stateProperties } - // tslint:disable-next-line no-unsafe-any diagnostics.push(...getDiagnosticsForNode(oneStateValueNode, document, allowedProperties)) } diff --git a/src/validation/validateStates.ts b/src/validation/validateStates.ts index 453b3576d..ffcb0ee1f 100755 --- a/src/validation/validateStates.ts +++ b/src/validation/validateStates.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: MIT */ -/* tslint:disable:cyclomatic-complexity */ - import { Diagnostic, DiagnosticSeverity, @@ -272,7 +270,6 @@ export default function validateStates( ?.valueNode?.value const nextNodeValue = nextPropNode?.valueNode?.value - const stateName = prop.keyNode.value if (endPropNode && endPropNode.valueNode?.value === true) { hasTerminalState = true diff --git a/src/yaml/aslYamlLanguageService.ts b/src/yaml/aslYamlLanguageService.ts index 6af1a8c01..83893e736 100644 --- a/src/yaml/aslYamlLanguageService.ts +++ b/src/yaml/aslYamlLanguageService.ts @@ -84,7 +84,7 @@ export const getLanguageService = function ( ...builtInParams, }) - const requestServiceMock = async function (uri: string): Promise { + const requestServiceMock = async function (_uri: string): Promise { return new Promise((c) => { c(JSON.stringify(schema)) }) @@ -167,7 +167,7 @@ export const getLanguageService = function ( }, } - const aslCompletions: CompletionList = doCompleteAsl( + const aslCompletions: CompletionList = await doCompleteAsl( processedDocument, tempPositionForCompletions, currentDoc, From 7b2745c642bf6e43cd0b7f4461bf8cd7e6fdb075 Mon Sep 17 00:00:00 2001 From: Silvia Chen Date: Fri, 31 Jan 2025 11:41:23 -0800 Subject: [PATCH 2/2] bump package version to 1.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 62f1525de..cae327103 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/aws/amazon-states-language-service" }, "license": "MIT", - "version": "1.14.0", + "version": "1.15.0", "publisher": "aws", "categories": [ "Programming Languages"