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 {
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;
try {
resolved = resolveAutocompleteExpression(`={{ ${base} }}`);
} catch (error) {
const resolved = attempt(
(): Resolved => resolveAutocompleteExpression(`={{ ${base} }}`),
(error) => {
if (!isPairedItemIntermediateNodesError(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,7 +135,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul
}
function explicitDataTypeOptions(expression: string): Completion[] {
try {
return attempt(
() => {
const resolved = resolveAutocompleteExpression(`={{ ${expression} }}`);
return datatypeOptions({
resolved,
@ -133,9 +144,9 @@ function explicitDataTypeOptions(expression: string): Completion[] {
tail: '',
transformLabel: (label) => '.' + label,
});
} catch {
return [];
}
},
() => [],
);
}
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<DateTime>);
}
@ -453,12 +470,13 @@ const applySections = ({
};
const isUrl = (url: string): boolean => {
try {
return attempt(
() => {
new URL(url);
return true;
} catch (error) {
return false;
}
},
() => false,
);
};
const stringOptions = (input: AutocompleteInput<string>): Completion[] => {

View file

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