fix(editor): Provide autocomplete for nodes, even when intermediate node has not run (#10036)

This commit is contained in:
Elias Meire 2024-07-12 17:32:50 +02:00 committed by GitHub
parent fd833ec079
commit 46d6edc2a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 191 additions and 39 deletions

View file

@ -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);
});
});
});

View file

@ -36,6 +36,8 @@ import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './
import { import {
applyBracketAccessCompletion, applyBracketAccessCompletion,
applyCompletion, applyCompletion,
attempt,
expressionWithFirstItem,
getDefaultArgs, getDefaultArgs,
getDisplayType, getDisplayType,
hasNoParams, hasNoParams,
@ -52,6 +54,8 @@ import {
splitBaseTail, splitBaseTail,
stripExcessParens, stripExcessParens,
} from './utils'; } from './utils';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { isPairedItemIntermediateNodesError } from '@/utils/expressions';
/** /**
* Resolution-based completions offered according to datatype. * 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; 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[] = []; let options: Completion[] = [];
@ -80,21 +85,26 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
} else if (base === '$secrets' && isCredential) { } else if (base === '$secrets' && isCredential) {
options = secretProvidersOptions(); options = secretProvidersOptions();
} else { } else {
let resolved: Resolved; const resolved = attempt(
(): Resolved => resolveAutocompleteExpression(`={{ ${base} }}`),
try { (error) => {
resolved = resolveAutocompleteExpression(`={{ ${base} }}`); if (!isPairedItemIntermediateNodesError(error)) {
} catch (error) {
return null; 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; if (resolved === null) return null;
try { options = attempt(
options = datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)); () => datatypeOptions({ resolved, base, tail }).map(stripExcessParens(context)),
} catch (error) { () => [],
return null; );
}
} }
if (tail !== '') { if (tail !== '') {
@ -125,7 +135,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
} }
function explicitDataTypeOptions(expression: string): Completion[] { function explicitDataTypeOptions(expression: string): Completion[] {
try { return attempt(
() => {
const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`); const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`);
return datatypeOptions({ return datatypeOptions({
resolved, resolved,
@ -133,9 +144,9 @@ function explicitDataTypeOptions(expression: string): Completion[] {
tail: '', tail: '',
transformLabel: (label) => '.' + label, transformLabel: (label) => '.' + label,
}); });
} catch { },
return []; () => [],
} );
} }
function datatypeOptions(input: AutocompleteInput): Completion[] { function datatypeOptions(input: AutocompleteInput): Completion[] {
@ -155,7 +166,13 @@ function datatypeOptions(input: AutocompleteInput): Completion[] {
return booleanOptions(); 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<DateTime>); return luxonOptions(input as AutocompleteInput<DateTime>);
} }
@ -453,12 +470,13 @@ const applySections = ({
}; };
const isUrl = (url: string): boolean => { const isUrl = (url: string): boolean => {
try { return attempt(
() => {
new URL(url); new URL(url);
return true; return true;
} catch (error) { },
return false; () => false,
} );
}; };
const stringOptions = (input: AutocompleteInput<string>): Completion[] => { const stringOptions = (input: AutocompleteInput<string>): Completion[] => {

View file

@ -16,8 +16,7 @@ import {
} from '@codemirror/autocomplete'; } from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view'; import type { EditorView } from '@codemirror/view';
import type { TransactionSpec } from '@codemirror/state'; import type { TransactionSpec } from '@codemirror/state';
import type { SyntaxNode } from '@lezer/common'; import type { SyntaxNode, Tree } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { DocMetadata } from 'n8n-workflow'; import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/utils/mappingUtils'; 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). * Split user input into base (to resolve) and tail (to filter).
*/ */
export function splitBaseTail(userInput: string): [string, string] { export function splitBaseTail(syntaxTree: Tree, userInput: string): [string, string] {
const read = (node: SyntaxNode | null) => (node ? userInput.slice(node.from, node.to) : ''); const lastNode = syntaxTree.resolveInner(userInput.length, -1);
const lastNode = javascriptLanguage.parser.parse(userInput).resolveInner(userInput.length, -1);
switch (lastNode.type.name) { switch (lastNode.type.name) {
case '.': case '.':
return [read(lastNode.parent).slice(0, -1), '']; return [read(lastNode.parent, userInput).slice(0, -1), ''];
case 'MemberExpression': case 'MemberExpression':
return [read(lastNode.parent), read(lastNode)]; return [read(lastNode.parent, userInput), read(lastNode, userInput)];
case 'PropertyName': case 'PropertyName':
const tail = read(lastNode); const tail = read(lastNode, userInput);
return [read(lastNode.parent).slice(0, -(tail.length + 1)), tail]; return [read(lastNode.parent, userInput).slice(0, -(tail.length + 1)), tail];
default: default:
return ['', '']; 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[]) { export function longestCommonPrefix(...strings: string[]) {
if (strings.length < 2) { if (strings.length < 2) {
throw new Error('Expected at least two strings'); throw new Error('Expected at least two strings');
@ -285,3 +354,22 @@ export const getDisplayType = (value: unknown): string => {
if (typeof value === 'object') return 'Object'; if (typeof value === 'object') return 'Object';
return (typeof value).toLocaleLowerCase(); return (typeof value).toLocaleLowerCase();
}; };
export function attempt<T, TDefault>(
fn: () => T,
onError: (error: unknown) => TDefault,
): T | TDefault;
export function attempt<T>(fn: () => T): T | null;
export function attempt<T, TDefault>(
fn: () => T,
onError?: (error: unknown) => TDefault,
): T | TDefault | null {
try {
return fn();
} catch (error) {
if (onError) {
return onError(error);
}
return null;
}
}