diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.test.ts new file mode 100644 index 0000000000..c71b15a87f --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/utils.test.ts @@ -0,0 +1,46 @@ +import { expressionWithFirstItem } from '../utils'; +import { javascriptLanguage } from '@codemirror/lang-javascript'; + +describe('completion utils', () => { + describe('expressionWithFirstItem', () => { + it('should replace $input.item', () => { + const source = '$input.item.json.foo.bar'; + const expected = '$input.first().json.foo.bar'; + const tree = javascriptLanguage.parser.parse(source); + const result = expressionWithFirstItem(tree, source); + expect(result).toBe(expected); + }); + + it('should replace $input.itemMatching()', () => { + const source = '$input.itemMatching(4).json.foo.bar'; + const expected = '$input.first().json.foo.bar'; + const tree = javascriptLanguage.parser.parse(source); + const result = expressionWithFirstItem(tree, source); + expect(result).toBe(expected); + }); + + it('should replace $("Node Name").itemMatching()', () => { + const source = '$("Node Name").itemMatching(4).json.foo.bar'; + const expected = '$("Node Name").first().json.foo.bar'; + const tree = javascriptLanguage.parser.parse(source); + const result = expressionWithFirstItem(tree, source); + expect(result).toBe(expected); + }); + + it('should replace $("Node Name").item', () => { + const source = '$("Node Name").item.json.foo.bar'; + const expected = '$("Node Name").first().json.foo.bar'; + const tree = javascriptLanguage.parser.parse(source); + const result = expressionWithFirstItem(tree, source); + expect(result).toBe(expected); + }); + + it('should not replace anything in unrelated expressions', () => { + const source = '$input.first().foo.item.fn($json.item.foo)'; + const expected = '$input.first().foo.item.fn($json.item.foo)'; + const tree = javascriptLanguage.parser.parse(source); + const result = expressionWithFirstItem(tree, source); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 60ba5c977b..25d88d1d01 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -36,6 +36,8 @@ import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './ import { applyBracketAccessCompletion, applyCompletion, + attempt, + expressionWithFirstItem, getDefaultArgs, getDisplayType, hasNoParams, @@ -52,6 +54,8 @@ import { splitBaseTail, stripExcessParens, } from './utils'; +import { javascriptLanguage } from '@codemirror/lang-javascript'; +import { isPairedItemIntermediateNodesError } from '@/utils/expressions'; /** * Resolution-based completions offered according to datatype. @@ -63,7 +67,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - const [base, tail] = splitBaseTail(word.text); + const syntaxTree = javascriptLanguage.parser.parse(word.text); + const [base, tail] = splitBaseTail(syntaxTree, word.text); let options: Completion[] = []; @@ -80,21 +85,26 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul } else if (base === '$secrets' && isCredential) { options = secretProvidersOptions(); } else { - let resolved: Resolved; + const resolved = attempt( + (): Resolved => resolveAutocompleteExpression(`={{ ${base} }}`), + (error) => { + if (!isPairedItemIntermediateNodesError(error)) { + return null; + } - try { - resolved = resolveAutocompleteExpression(`={{ ${base} }}`); - } catch (error) { - return null; - } + // Fallback on first item to provide autocomplete when intermediate nodes have not run + return attempt(() => + resolveAutocompleteExpression(`={{ ${expressionWithFirstItem(syntaxTree, base)} }}`), + ); + }, + ); if (resolved === null) return null; - try { - options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)); - } catch (error) { - return null; - } + options = attempt( + () => datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)), + () => [], + ); } if (tail !== '') { @@ -125,17 +135,18 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul } function explicitDataTypeOptions(expression: string): Completion[] { - try { - const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`); - return datatypeOptions({ - resolved, - base: expression, - tail: '', - transformLabel: (label) => '.' + label, - }); - } catch { - return []; - } + return attempt( + () => { + const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`); + return datatypeOptions({ + resolved, + base: expression, + tail: '', + transformLabel: (label) => '.' + label, + }); + }, + () => [], + ); } function datatypeOptions(input: AutocompleteInput): Completion[] { @@ -155,7 +166,13 @@ function datatypeOptions(input: AutocompleteInput): Completion[] { return booleanOptions(); } - if (DateTime.isDateTime(resolved)) { + if ( + attempt( + // This can throw when resolved is a proxy + () => DateTime.isDateTime(resolved), + () => false, + ) + ) { return luxonOptions(input as AutocompleteInput); } @@ -453,12 +470,13 @@ const applySections = ({ }; const isUrl = (url: string): boolean => { - try { - new URL(url); - return true; - } catch (error) { - return false; - } + return attempt( + () => { + new URL(url); + return true; + }, + () => false, + ); }; const stringOptions = (input: AutocompleteInput): Completion[] => { diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 88aea8f0f5..0286405b1a 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -16,8 +16,7 @@ import { } from '@codemirror/autocomplete'; import type { EditorView } from '@codemirror/view'; import type { TransactionSpec } from '@codemirror/state'; -import type { SyntaxNode } from '@lezer/common'; -import { javascriptLanguage } from '@codemirror/lang-javascript'; +import type { SyntaxNode, Tree } from '@lezer/common'; import { useRouter } from 'vue-router'; import type { DocMetadata } from 'n8n-workflow'; import { escapeMappingString } from '@/utils/mappingUtils'; @@ -25,23 +24,93 @@ import { escapeMappingString } from '@/utils/mappingUtils'; /** * Split user input into base (to resolve) and tail (to filter). */ -export function splitBaseTail(userInput: string): [string, string] { - const read = (node: SyntaxNode | null) => (node ? userInput.slice(node.from, node.to) : ''); - const lastNode = javascriptLanguage.parser.parse(userInput).resolveInner(userInput.length, -1); +export function splitBaseTail(syntaxTree: Tree, userInput: string): [string, string] { + const lastNode = syntaxTree.resolveInner(userInput.length, -1); switch (lastNode.type.name) { case '.': - return [read(lastNode.parent).slice(0, -1), '']; + return [read(lastNode.parent, userInput).slice(0, -1), '']; case 'MemberExpression': - return [read(lastNode.parent), read(lastNode)]; + return [read(lastNode.parent, userInput), read(lastNode, userInput)]; case 'PropertyName': - const tail = read(lastNode); - return [read(lastNode.parent).slice(0, -(tail.length + 1)), tail]; + const tail = read(lastNode, userInput); + return [read(lastNode.parent, userInput).slice(0, -(tail.length + 1)), tail]; default: return ['', '']; } } +function replaceSyntaxNode(source: string, node: SyntaxNode, replacement: string) { + return source.slice(0, node.from) + replacement + source.slice(node.to); +} + +function isInputNodeCall(node: SyntaxNode, source: string): node is SyntaxNode { + return ( + node.name === 'VariableName' && + read(node, source) === '$' && + node.parent?.name === 'CallExpression' + ); +} + +function isInputVariable(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode { + return node?.name === 'VariableName' && read(node, source) === '$input'; +} + +function isItemProperty(node: SyntaxNode | null | undefined, source: string): node is SyntaxNode { + return ( + node?.parent?.name === 'MemberExpression' && + node.name === 'PropertyName' && + read(node, source) === 'item' + ); +} + +function isItemMatchingCall( + node: SyntaxNode | null | undefined, + source: string, +): node is SyntaxNode { + return ( + node?.name === 'CallExpression' && + node.firstChild?.lastChild?.name === 'PropertyName' && + read(node.firstChild.lastChild, source) === 'itemMatching' + ); +} + +function read(node: SyntaxNode | null, source: string) { + return node ? source.slice(node.from, node.to) : ''; +} +/** + * Replace expressions that depend on pairedItem with the first item when possible + * $input.item.json.foo -> $input.first().json.foo + * $('Node').item.json.foo -> $('Node').item.json.foo + */ +export function expressionWithFirstItem(syntaxTree: Tree, expression: string): string { + let result = expression; + + syntaxTree.cursor().iterate(({ node }) => { + if (isInputVariable(node, expression)) { + if (isItemProperty(node.parent?.lastChild, expression)) { + result = replaceSyntaxNode(expression, node.parent.lastChild, 'first()'); + } else if (isItemMatchingCall(node.parent?.parent, expression)) { + result = replaceSyntaxNode(expression, node.parent.parent, '$input.first()'); + } + } + + if (isInputNodeCall(node, expression)) { + if (isItemProperty(node.parent?.parent?.lastChild, expression)) { + result = replaceSyntaxNode(expression, node.parent.parent.lastChild, 'first()'); + } else if (isItemMatchingCall(node.parent?.parent?.parent, expression)) { + result = replaceSyntaxNode( + expression, + node.parent.parent.parent, + `${read(node.parent, expression)}.first()`, + ); + } + } + }); + + return result; +} + export function longestCommonPrefix(...strings: string[]) { if (strings.length < 2) { throw new Error('Expected at least two strings'); @@ -285,3 +354,22 @@ export const getDisplayType = (value: unknown): string => { if (typeof value === 'object') return 'Object'; return (typeof value).toLocaleLowerCase(); }; + +export function attempt( + fn: () => T, + onError: (error: unknown) => TDefault, +): T | TDefault; +export function attempt(fn: () => T): T | null; +export function attempt( + fn: () => T, + onError?: (error: unknown) => TDefault, +): T | TDefault | null { + try { + return fn(); + } catch (error) { + if (onError) { + return onError(error); + } + return null; + } +}