diff --git a/packages/common/src/constants/regexp.ts b/packages/common/src/constants/regexp.ts index 02c9375c8..c8e97eacc 100644 --- a/packages/common/src/constants/regexp.ts +++ b/packages/common/src/constants/regexp.ts @@ -6,8 +6,20 @@ export const SLOT_ANNOTATION_SIMPLE_REGEX = /{([^ .[\]{}]+?)}/g; export const IS_VARIABLE_REGEXP = /^{.*}$/; -// export const READABLE_VARIABLE_REGEXP = /{(\w{1,64})}/g; -export const READABLE_VARIABLE_REGEXP = /{(\w{1,64})((?:\.\w{1,64}|\[\d+])*)}/gi; +/** + * Matches: + * {var} + * {var.x} + * {var.x.y} + * {var.x.y[0]} + * {var.x.y[other]} + * {var[0]} + * {var[other]} + * {var[0].x} + * {var[other].y} + * ... etc + */ +export const READABLE_VARIABLE_REGEXP = /{(\w{1,64})((?:\.\w{1,64}|\[\d]|\[\w{1,64}])*)}/gi; export const VALID_CHARACTER = 'a-zA-Z'; diff --git a/packages/common/src/utils/string.ts b/packages/common/src/utils/string.ts index f90b7a07c..4a5d999ef 100644 --- a/packages/common/src/utils/string.ts +++ b/packages/common/src/utils/string.ts @@ -15,3 +15,22 @@ export const removeTrailingUnderscores = (str: string): string => str.replace(TR export const conditionalReplace = (base: string, pattern: RegExp, value?: string) => { return value ? base.replace(pattern, value) : base; }; + +/** + * Recursively call String.prototype.replace until the result is unchanged. + * This is more useful when the `replacer` may end up returning something that should be replaced. + * + * For simple "recursive" replacements use the global RegExp flag (`g`) and not this method. + */ +export const recursiveReplace = ( + str: string, + searchValue: string | RegExp, + replacer: (substring: string, ...args: any[]) => string, + maxDepth = Infinity, + currentDepth = 0 +): string => { + const replacedString = str.replace(searchValue, replacer); + if (replacedString === str) return replacedString; + if (currentDepth >= maxDepth) return replacedString; + return recursiveReplace(replacedString, searchValue, replacer, maxDepth, currentDepth + 1); +}; diff --git a/packages/common/src/utils/variables.ts b/packages/common/src/utils/variables.ts index 829c10ae6..254d435c2 100644 --- a/packages/common/src/utils/variables.ts +++ b/packages/common/src/utils/variables.ts @@ -1,53 +1,47 @@ import { READABLE_VARIABLE_REGEXP } from '@common/constants'; +import { hasProperty } from '@common/utils/object'; +import { recursiveReplace } from '@common/utils/string'; +import { get } from 'lodash'; + +const resolveVariableSelectorPath = (variableValue: unknown, trimmedSelectorPath: string, defaultValue?: string) => { + if (trimmedSelectorPath) { + return typeof variableValue === 'object' ? get(variableValue, trimmedSelectorPath, defaultValue) : defaultValue; + } + return variableValue; +}; export const variableReplacer = ( - match: string, - inner: string, - selectors: string[], + matchedString: string, + variableName: string, + selectorPath: string, variables: Record, modifier?: (variable: unknown) => unknown ): unknown => { - if (!(inner in variables)) { - return match; - } + if (!hasProperty(variables, variableName)) return matchedString; - let replaced: any = variables[inner]; - - let selectorString = selectors[0]; - while (selectorString.length > 0) { - // eslint-disable-next-line no-loop-func - selectorString = selectorString.replace(/^\.(\w{1,64})/, (_m, field) => { - replaced = replaced[field]; - return ''; - }); - if (replaced === undefined) { - break; - } - // eslint-disable-next-line no-loop-func - selectorString = selectorString.replace(/^\[(\d+)]/, (_m, index) => { - replaced = replaced[index]; - return ''; - }); - if (replaced === undefined) { - break; - } - } + const variableValue = variables[variableName]; + const trimmedSelectorPath = selectorPath.startsWith('.') ? selectorPath.substring(1) : selectorPath; + + const resolvedValue = resolveVariableSelectorPath(variableValue, trimmedSelectorPath, matchedString); - return typeof modifier === 'function' ? modifier(replaced) : replaced; + return typeof modifier === 'function' ? modifier(resolvedValue) : resolvedValue; }; export const replaceVariables = ( phrase: string | undefined | null, variables: Record, - modifier: ((variable: unknown) => unknown) | undefined = undefined, + modifier?: (variable: unknown) => unknown, { trim = true }: { trim?: boolean } = {} ): string => { - if (!phrase || (trim && !phrase.trim())) { - return ''; - } - - return phrase.replace(READABLE_VARIABLE_REGEXP, (match, inner, ...selectors) => - String(variableReplacer(match, inner, selectors, variables, modifier)) + const trimmedPhrase = trim ? phrase?.trim() : phrase; + if (!trimmedPhrase) return ''; + + return recursiveReplace( + trimmedPhrase, + READABLE_VARIABLE_REGEXP, + (matchedString, variableName: string, selectorPath: string) => + String(variableReplacer(matchedString, variableName, selectorPath, variables, modifier)), + 10 ); }; diff --git a/packages/common/tests/utils/string.unit.ts b/packages/common/tests/utils/string.unit.ts new file mode 100644 index 000000000..41faa6346 --- /dev/null +++ b/packages/common/tests/utils/string.unit.ts @@ -0,0 +1,14 @@ +import { recursiveReplace } from '@common/utils/string'; +import { expect } from 'chai'; + +describe('Utils | string', () => { + describe('recursiveReplace', () => { + it('replaces until it cannot replace any more', () => { + // act + const result = recursiveReplace('heeeeeeello', 'ee', () => 'e'); + + // assert + expect(result).to.equal('hello'); + }); + }); +}); diff --git a/packages/common/tests/utils/variables.unit.ts b/packages/common/tests/utils/variables.unit.ts index d23123b70..84e2ff1e8 100644 --- a/packages/common/tests/utils/variables.unit.ts +++ b/packages/common/tests/utils/variables.unit.ts @@ -3,48 +3,215 @@ import { expect } from 'chai'; describe('Utils | variables', () => { describe('replaceVariables', () => { - const variables = { - name: 'bob', - age: 32, - favFoods: ['takoyaki', 'onigiri', 'taiyaki'], - job: { - company: 'voiceflow', - position: 'software engineer', - team: 'creator', - }, - }; - it('correctly replaces simple variables', () => { - expect(replaceVariables('hello, my name is {name}, and i am {age} years old', variables)).to.eq('hello, my name is bob, and i am 32 years old'); - expect(replaceVariables('{name} {name} {name}', variables)).to.eq('bob bob bob'); - }); - it('variables that are not defined do not get expanded', () => { - expect(replaceVariables('hello, my name is {name} and i work at {workplace}', variables)).to.eq( - 'hello, my name is bob and i work at {workplace}' - ); - expect(replaceVariables('hello, my name is {Name}', variables)).to.eq('hello, my name is {Name}'); - }); - - it('array access works', () => { - expect(replaceVariables('most favorite food is {favFoods[0]}, second favorite is {favFoods[1]}, and third is {favFoods[2]}', variables)).to.eq( - 'most favorite food is takoyaki, second favorite is onigiri, and third is taiyaki' - ); - }); - // it('index out of range', () => {}); - - it('object access works', () => { - expect(replaceVariables('i work at {job.company} as a {job.position} on the {job.team} team', variables)).to.eq( - 'i work at voiceflow as a software engineer on the creator team' - ); - }); - // it('non-existent fields', () => {}); - - it('weird cases', () => { - const variables = { - '': 6969, - var: '{name}', - }; - expect(replaceVariables('this is a blank variable {}', variables)).to.eq('this is a blank variable {}'); - expect(replaceVariables('{var}', variables)).to.eq('{name}'); + it('replaces variable annotations with no selector and trims the result by default', () => { + // arrange + const variables = { + name: 'bob', + age: 32, + }; + + // act + const result = replaceVariables(' hello, my name is {name}, and i am {age} years old ', variables); + + // assert + expect(result).to.equal('hello, my name is bob, and i am 32 years old'); + }); + + it('replaces variable annotations and applies a modifier', () => { + // arrange + const variables = { + age: 20, + }; + + // act + const result = replaceVariables('i am {age} years old', variables, (value) => (typeof value === 'number' ? value * 2 : value)); + + // assert + expect(result).to.equal('i am 40 years old'); + }); + + it('replaces variable annotations and does not trim the result', () => { + // arrange + const variables = { + value: 'world', + }; + + // act + const result = replaceVariables(' hello {value} ', variables, undefined, { trim: false }); + + // assert + expect(result).to.equal(' hello world '); + }); + + it('replaces variable annotations with a property selector', () => { + // arrange + const variables = { + job: { + company: 'voiceflow', + position: 'software engineer', + team: 'creator', + }, + }; + + // act + const result = replaceVariables('i work at {job.company} as a {job.position} on the {job.team} team', variables); + + // assert + expect(result).to.equal('i work at voiceflow as a software engineer on the creator team'); + }); + + it('does replaces variable annotations if the property does not exist', () => { + // arrange + const variables = { + obj: { + a: 1, + }, + }; + + // act + const result = replaceVariables('what is {obj.b}', variables); + + // assert + expect(result).to.equal('what is {obj.b}'); + }); + + it('replaces variable annotations with an element selector (array index)', () => { + // arrange + const variables = { + favFoods: ['takoyaki', 'onigiri', 'taiyaki'], + }; + + // act + const result = replaceVariables('most favorite food is {favFoods[0]}, second favorite is {favFoods[1]}, and third is {favFoods[2]}', variables); + + // assert + expect(result).to.equal('most favorite food is takoyaki, second favorite is onigiri, and third is taiyaki'); + }); + + it('recursively replaces variable annotations for element selectors (array index)', () => { + // arrange + const variables = { + payload: { + property: [1, 2], + }, + index: 1, + }; + + // act + const result = replaceVariables('the value is {payload.property[{index}]}', variables); + + // assert + expect(result).to.equal('the value is 2'); + }); + + it('recursively replaces variable annotations for element selectors (property name)', () => { + // arrange + const variables = { + payload: { + key: { + value: 'hello', + }, + }, + property: { + value: 'key', + }, + }; + + // act + const result = replaceVariables('the value is {payload[{property.value}].value}', variables); + + // assert + expect(result).to.equal('the value is hello'); + }); + + it('replaces variable annotations using a variable annotation returned from a previous lookup', () => { + // arrange + const variables = { + payload: { + key: { + value: 'world', + }, + }, + property: { + value: '{lookup}', + }, + lookup: 'key', + }; + + // act + const result = replaceVariables('the value is {payload[{property.value}].value}', variables); + + // assert + expect(result).to.equal('the value is world'); + }); + + it('resolves closely nested variable annotations', () => { + // arrange + const variables = { + a: 'b', + b: ':)', + }; + + // act + const result = replaceVariables('this is a weird nested variable {{a}}', variables); + + // assert + expect(result).to.equal('this is a weird nested variable :)'); + }); + + it('does not resolve a non-object property selector', () => { + // arrange + const variables = { + a: 1, + }; + + // act + const result = replaceVariables('accessing non-object {a.b}', variables); + + // assert + expect(result).to.equal('accessing non-object {a.b}'); + }); + + it('does not resolve an array index that is too large', () => { + // arrange + const variables = { + a: [1, 2], + }; + + // act + const result = replaceVariables('accessing index {a[100]}', variables); + + // assert + expect(result).to.equal('accessing index {a[100]}'); + }); + + describe('edge cases', () => { + it('ignores empty variable annotations', () => { + // arrange + const variables = { + '': 123, + }; + + // act + const result = replaceVariables('this is a blank variable {}', variables); + + // assert + expect(result).to.equal('this is a blank variable {}'); + }); + + it('breaks during variable annotation reference loop', () => { + // arrange + const variables = { + a: '{b}', + b: '{a}', + }; + + // act + const result = replaceVariables('{a}', variables); + + // assert + expect(result).to.equal('{b}'); + }); }); }); });