From c008e874e56b60154ca52cd8bc10d9baaa129d20 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 22 Jan 2026 17:06:53 +0100 Subject: [PATCH] Allow variables with dots and keys (#17361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/twentyhq/private-issues/issues/410#issuecomment-3781085655 Currently, JSON keys with spaces like { "toto toto": 123 } are rejected with "JSON keys cannot contain spaces" error. This is problematic for HTTP requests and webhook triggers where users cannot control the response structure. We use Handlebars to eval variables, segment-literal bracket notation to escape keys with special characters: Normal: {{step.normalKey}} With spaces: `{{step.[key with space]}}` So we simply need to wrap segments with spaces with brackets. This PR: - Create shared path utilities to wrap the variable segments when needed - Use it in all variable generation places - Remove the restrictions This body is now supported: Capture d’écran 2026-01-22 à 16 10
13 --- ...ateVariableFriendlyStringifiedJson.test.ts | 47 ------ ...ValidateVariableFriendlyStringifiedJson.ts | 6 +- .../utils/getVariableTemplateFromPath.ts | 4 +- .../searchVariableThroughBaseOutputSchema.ts | 7 +- ...hVariableThroughFindRecordsOutputSchema.ts | 7 +- .../searchVariableThroughFormOutputSchema.ts | 7 +- ...archVariableThroughIteratorOutputSchema.ts | 11 +- ...hVariableThroughRecordEventOutputSchema.ts | 9 +- ...searchVariableThroughRecordOutputSchema.ts | 7 +- .../extract-property-path-from-variable.ts | 7 +- .../utils/__tests__/variable-resolver.test.ts | 50 ++++++ packages/twenty-shared/src/workflow/index.ts | 6 + .../__tests__/variable-path.util.test.ts | 146 ++++++++++++++++++ .../src/workflow/utils/variable-path.util.ts | 70 +++++++++ 14 files changed, 317 insertions(+), 67 deletions(-) create mode 100644 packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts create mode 100644 packages/twenty-shared/src/workflow/utils/variable-path.util.ts diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts index b090109fb9e..a088ceb9c1f 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts @@ -71,53 +71,6 @@ describe('parseAndValidateVariableFriendlyStringifiedJson', () => { }); }); - describe('Invalid keys with whitespace', () => { - it('should reject key with space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"key with space": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with leading space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{" leadingSpace": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with trailing space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"trailingSpace ": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with tab character', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"key\\twith\\ttab": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject when one of multiple keys has space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"validKey": "value", "invalid key": "another"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - }); - describe('Malformed JSON', () => { it('should reject invalid JSON syntax', () => { const result = diff --git a/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts index ac6390d2499..242eed50d41 100644 --- a/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts +++ b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts @@ -1,10 +1,6 @@ import { z } from 'zod'; -const schema = z - .record(z.string(), z.any()) - .refine((data) => Object.keys(data).every((key) => !key.match(/\s/)), { - error: 'JSON keys cannot contain spaces', - }); +const schema = z.record(z.string(), z.any()); export const parseAndValidateVariableFriendlyStringifiedJson = ( expectedJson: string, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts index 321bc528ecc..f166b02b47e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts @@ -1,3 +1,5 @@ +import { joinVariablePath } from 'twenty-shared/workflow'; + export const getVariableTemplateFromPath = ({ stepId, path, @@ -9,5 +11,5 @@ export const getVariableTemplateFromPath = ({ return `{{${stepId}}}`; } - return `{{${stepId}.${path.join('.')}}}`; + return `{{${stepId}.${joinVariablePath(path)}}}`; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts index dda08f8d490..8918f0ebcd1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts @@ -2,16 +2,21 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import { isDefined } from 'twenty-shared/utils'; import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, type BaseOutputSchemaV2, } from 'twenty-shared/workflow'; +/** + * Parses a variable name to extract its components + * Example: "{{step1.field.value}}" -> { stepId: "step1", pathSegments: ["field"], targetFieldName: "value" } + */ const parseVariableName = (rawVariableName: string) => { const variableWithoutBrackets = rawVariableName.replace( CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); return { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts index cd8b157d2e0..5047f2b5a9f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts @@ -3,7 +3,10 @@ import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/type import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; type SearchResultKey = 'first' | 'all' | 'totalCount'; @@ -18,7 +21,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const searchResultKey = parts.at(1) as SearchResultKey; const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts index a1a98bff716..43e43b53883 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts @@ -2,7 +2,10 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema'; import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; /** * Parses a variable name to extract its components for Form outputs @@ -15,7 +18,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const fieldName = parts.at(1); const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts index 5fb51fa03b5..5bee261aa45 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts @@ -6,20 +6,27 @@ import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/sear import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; type IteratorResultKey = | 'currentItem' | 'currentItemIndex' | 'hasProcessedAllItems'; +/** + * Parses a variable name to extract its components + * Example: "{{step1.currentItem.field}}" -> { stepId: "step1", iteratorResultKey: "currentItem", pathSegments: [], fieldName: "field" } + */ const parseVariableName = (rawVariableName: string) => { const variableWithoutBrackets = rawVariableName.replace( CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const iteratorResultKey = parts.at(1) as IteratorResultKey; const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts index d647c39bc44..16aef78b1bb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts @@ -2,7 +2,10 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; /** * Parses a variable name to extract its components @@ -14,10 +17,10 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); // after stepId, we have a prefix (properties.after or properties.before). Path segments are the rest of the string - // join the first 3 parts to get the event prefix + // join the next 3 parts to get the event prefix (properties, after/before, objectName) const firstFieldWithEventPrefix = parts.slice(1, 4).join('.'); const remainingParts = parts.slice(4); const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts index 6c14f57ee03..5bae4ccfa53 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts @@ -6,7 +6,10 @@ import { } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; const getRecordObjectLabel = ( recordSchema: RecordOutputSchemaV2, @@ -160,7 +163,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); return { stepId: parts.at(0), diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts index 00e310390ac..082c3d0b9f2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts @@ -1,4 +1,7 @@ -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; export const extractPropertyPathFromVariable = ( rawVariableName: string, @@ -8,7 +11,7 @@ export const extractPropertyPathFromVariable = ( (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); return parts.slice(1); }; diff --git a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts index c7e9402b7a3..4912493359b 100644 --- a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts @@ -129,4 +129,54 @@ describe('resolveInput', () => { }), ).toBe('{ "a": "str" }'); }); + + describe('bracket notation for keys with special characters', () => { + it('should resolve variables with keys containing spaces', () => { + const contextWithSpaces = { + step: { + 'key with space': 'value from space key', + }, + }; + expect(resolveInput('{{step.[key with space]}}', contextWithSpaces)).toBe( + 'value from space key', + ); + }); + + it('should resolve nested variables with keys containing spaces', () => { + const contextWithSpaces = { + step: { + 'first key': { + 'nested key': 'nested value', + }, + }, + }; + expect( + resolveInput('{{step.[first key].[nested key]}}', contextWithSpaces), + ).toBe('nested value'); + }); + + it('should resolve mixed normal and bracket notation paths', () => { + const contextWithMixed = { + step: { + normal: { + 'key with space': 42, + }, + }, + }; + expect( + resolveInput('{{step.normal.[key with space]}}', contextWithMixed), + ).toBe(42); + }); + + it('should resolve variables with keys containing dots', () => { + const contextWithDots = { + step: { + 'key.with.dots': 'dotted value', + }, + }; + expect(resolveInput('{{step.[key.with.dots]}}', contextWithDots)).toBe( + 'dotted value', + ); + }); + }); }); diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 27321ec42a6..be58f0d7fe9 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -75,6 +75,12 @@ export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts' export { getWorkflowRunContext } from './utils/getWorkflowRunContext'; export { parseBooleanFromStringValue } from './utils/parseBooleanFromStringValue'; export { parseDataFromContentType } from './utils/parseDataFromContentType'; +export { + needsEscaping, + escapePathSegment, + joinVariablePath, + parseVariablePath, +} from './utils/variable-path.util'; export type { LeafType, NodeType, diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts new file mode 100644 index 00000000000..e7f77a50586 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts @@ -0,0 +1,146 @@ +import { + escapePathSegment, + joinVariablePath, + needsEscaping, + parseVariablePath, +} from '../variable-path.util'; + +describe('variable path utility functions', () => { + describe('needsEscaping', () => { + it('should return true for keys with spaces', () => { + expect(needsEscaping('key with space')).toBe(true); + expect(needsEscaping('toto toto')).toBe(true); + }); + + it('should return true for keys with dots', () => { + expect(needsEscaping('key.with.dots')).toBe(true); + }); + + it('should return true for keys with brackets', () => { + expect(needsEscaping('key[0]')).toBe(true); + expect(needsEscaping('[key]')).toBe(true); + }); + + it('should return false for simple keys', () => { + expect(needsEscaping('simpleKey')).toBe(false); + expect(needsEscaping('camelCase')).toBe(false); + expect(needsEscaping('snake_case')).toBe(false); + expect(needsEscaping('kebab-case')).toBe(false); + }); + + describe('escapePathSegment', () => { + it('should wrap keys with spaces in brackets', () => { + expect(escapePathSegment('key with space')).toBe('[key with space]'); + }); + + it('should wrap keys with dots in brackets', () => { + expect(escapePathSegment('key.with.dots')).toBe('[key.with.dots]'); + }); + + it('should not modify simple keys', () => { + expect(escapePathSegment('simpleKey')).toBe('simpleKey'); + }); + }); + + describe('joinVariablePath', () => { + it('should join simple segments with dots', () => { + expect(joinVariablePath(['step', 'field', 'value'])).toBe( + 'step.field.value', + ); + }); + + it('should escape segments with spaces', () => { + expect(joinVariablePath(['step', 'key with space', 'value'])).toBe( + 'step.[key with space].value', + ); + }); + + it('should escape segments with dots', () => { + expect(joinVariablePath(['step', 'key.with.dots'])).toBe( + 'step.[key.with.dots]', + ); + }); + + it('should handle mixed simple and special segments', () => { + expect( + joinVariablePath(['step', 'normal', 'has space', 'another']), + ).toBe('step.normal.[has space].another'); + }); + + it('should handle empty array', () => { + expect(joinVariablePath([])).toBe(''); + }); + + it('should handle single segment', () => { + expect(joinVariablePath(['step'])).toBe('step'); + expect(joinVariablePath(['key with space'])).toBe('[key with space]'); + }); + }); + + describe('parseVariablePath', () => { + it('should parse simple dot-separated path', () => { + expect(parseVariablePath('step.field.value')).toEqual([ + 'step', + 'field', + 'value', + ]); + }); + + it('should parse path with bracketed segments containing spaces', () => { + expect(parseVariablePath('step.[key with space].value')).toEqual([ + 'step', + 'key with space', + 'value', + ]); + }); + + it('should parse path with bracketed segments containing dots', () => { + expect(parseVariablePath('step.[key.with.dots]')).toEqual([ + 'step', + 'key.with.dots', + ]); + }); + + it('should handle multiple bracketed segments', () => { + expect( + parseVariablePath('[first key].[second key].[third key]'), + ).toEqual(['first key', 'second key', 'third key']); + }); + + it('should handle mixed simple and bracketed segments', () => { + expect(parseVariablePath('step.normal.[has space].another')).toEqual([ + 'step', + 'normal', + 'has space', + 'another', + ]); + }); + + it('should handle empty string', () => { + expect(parseVariablePath('')).toEqual([]); + }); + + it('should handle single segment', () => { + expect(parseVariablePath('step')).toEqual(['step']); + expect(parseVariablePath('[key with space]')).toEqual([ + 'key with space', + ]); + }); + + it('should be inverse of joinVariablePath', () => { + const paths = [ + ['step', 'field', 'value'], + ['step', 'key with space', 'value'], + ['step', 'key.with.dots'], + ['step', 'normal', 'has space', 'another'], + ]; + + for (const path of paths) { + const joined = joinVariablePath(path); + const parsed = parseVariablePath(joined); + expect(parsed).toEqual(path); + } + }); + }); + }); +}); diff --git a/packages/twenty-shared/src/workflow/utils/variable-path.util.ts b/packages/twenty-shared/src/workflow/utils/variable-path.util.ts new file mode 100644 index 00000000000..c757811841d --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/variable-path.util.ts @@ -0,0 +1,70 @@ +// Characters that require bracket escaping in variable paths +// Spaces, dots, and brackets would break the dot-notation parsing +const SPECIAL_CHARS_REGEX = /[\s.[]/; + +export const needsEscaping = (key: string): boolean => + SPECIAL_CHARS_REGEX.test(key); + +export const escapePathSegment = (segment: string): string => + needsEscaping(segment) ? `[${segment}]` : segment; + +export const joinVariablePath = (segments: string[]): string => + segments.map(escapePathSegment).join('.'); + +/** + * Parses a variable path string into segments, handling bracket notation. + * Examples: + * "step.normal.key" => ["step", "normal", "key"] + * "step.[key with space].value" => ["step", "key with space", "value"] + * "step.[key.with.dots]" => ["step", "key.with.dots"] + */ +export const parseVariablePath = (path: string): string[] => { + const segments: string[] = []; + let current = ''; + let inBracket = false; + let segmentIndex = 0; + + while (segmentIndex < path.length) { + const char = path[segmentIndex]; + + if (char === '[' && !inBracket) { + if (current.length > 0) { + segments.push(current); + current = ''; + } + inBracket = true; + segmentIndex++; + continue; + } + + if (char === ']' && inBracket) { + segments.push(current); + current = ''; + inBracket = false; + segmentIndex++; + // Skip the following dot if present + if (segmentIndex < path.length && path[segmentIndex] === '.') { + segmentIndex++; + } + continue; + } + + if (char === '.' && !inBracket) { + if (current.length > 0) { + segments.push(current); + current = ''; + } + segmentIndex++; + continue; + } + + current += char; + segmentIndex++; + } + + if (current.length > 0) { + segments.push(current); + } + + return segments; +};