feat(editor): Introduce proxy completions to expressions (#5075)

*  Introduce proxy completions to expressions

* 🧪 Add tests

*  Replace snippet with alphabetic char completions

*  Tighten `DateTime` check

* 🧹 Clean up `n8nLang`

* 🔥 Remove duplicate

* 👕 Remove non-null assertion

*  Confirm that `overlay` is needed

* 🔥 Remove comment

* 🔥 Remove more unneeded code

* 🔥 Remove unneded Pinia setup

*  Simplify syntax
This commit is contained in:
Iván Ovejero 2023-01-06 10:07:36 +01:00 committed by GitHub
parent 77031a2950
commit f4140d011f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1391 additions and 520 deletions

View file

@ -27,9 +27,9 @@
"test:dev": "vitest"
},
"dependencies": {
"@codemirror/autocomplete": "^6.1.0",
"@codemirror/autocomplete": "^6.4.0",
"@codemirror/commands": "^6.1.0",
"@codemirror/lang-javascript": "^6.0.2",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.2.1",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.4",

View file

@ -8,7 +8,7 @@ import {
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete';
import { acceptCompletion } from '@codemirror/autocomplete';
import {
history,
indentWithTab,
@ -16,11 +16,8 @@ import {
toggleComment,
} from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { customInputHandler } from './inputHandler';
const [_, bracketState] = closeBrackets() as readonly Extension[];
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
export const baseExtensions = [
lineNumbers(),
@ -29,7 +26,7 @@ export const baseExtensions = [
history(),
foldGutter(),
lintGutter(),
[customInputHandler, bracketState],
codeInputHandler(),
dropCursor(),
indentOnInput(),
bracketMatching(),

View file

@ -54,7 +54,7 @@ export const completerExtension = mixins(
// luxon
this.todayCompletions,
this.nowCompletions,
this.dateTimeCompltions,
this.dateTimeCompletions,
// item index
this.inputCompletions,
@ -174,7 +174,7 @@ export const completerExtension = mixins(
if (value === '$now') return this.nowCompletions(context, variable);
if (value === '$today') return this.todayCompletions(context, variable);
if (value === 'DateTime') return this.dateTimeCompltions(context, variable);
if (value === 'DateTime') return this.dateTimeCompletions(context, variable);
// item index

View file

@ -1,11 +1,12 @@
import Vue from 'vue';
import { isAllowedInDotNotation, escape, toVariableOption } from '../utils';
import { escape, toVariableOption } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject, IPinData, IRunData } from 'n8n-workflow';
import type { CodeNodeEditorMixin } from '../types';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils';
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
computed: {

View file

@ -76,7 +76,7 @@ export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
/**
* Complete `DateTime` with luxon `DateTime` static methods.
*/
dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null {
dateTimeCompletions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);

View file

@ -1,42 +0,0 @@
import { completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
* Customized input handler to prevent token autoclosing in certain cases.
*
* Based on: https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79
*/
export const customInputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens during autocompletion
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
from !== selection.from ||
to !== selection.to
) {
return false;
}
const transaction = insertBracket(view.state, insert);
if (!transaction) return false;
view.dispatch(transaction);
return true;
});

View file

@ -29,12 +29,6 @@ export function walk<T extends RangeNode>(
return found as T[];
}
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};
export const escape = (str: string) =>
str
.replace('$', '\\$')

View file

@ -10,13 +10,14 @@ import { history } from '@codemirror/commands';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { expressionManager } from '@/mixins/expressionManager';
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface';
import { forceParse } from '@/utils/forceParse';
import { autocompletion } from '@codemirror/autocomplete';
export default mixins(expressionManager, workflowHelpers).extend({
name: 'ExpressionEditorModalInput',
@ -36,9 +37,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
mounted() {
const extensions = [
inputTheme(),
n8nLanguageSupport(),
autocompletion(),
n8nLang(),
history(),
doubleBraceHandler(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly),
EditorView.domEventHandlers({ scroll: forceParse }),

View file

@ -13,9 +13,10 @@ import { useNDVStore } from '@/stores/ndv';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { expressionManager } from '@/mixins/expressionManager';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport';
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { inputTheme } from './theme';
import { autocompletion, ifIn } from '@codemirror/autocomplete';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
export default mixins(expressionManager, workflowHelpers).extend({
name: 'InlineExpressionEditorInput',
@ -39,35 +40,19 @@ export default mixins(expressionManager, workflowHelpers).extend({
},
watch: {
value(newValue) {
const payload: Record<string, unknown> = {
const isInternalChange = newValue === this.editor?.state.doc.toString();
if (isInternalChange) return;
// manual update on external change, e.g. from expression modal or mapping drop
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor?.state.doc.length,
insert: newValue,
},
selection: { anchor: this.cursorPosition, head: this.cursorPosition },
};
/**
* If completion from selection, preserve selection.
*/
if (this.editor) {
const [range] = this.editor.state.selection.ranges;
const isBraceAutoinsertion =
this.editor.state.sliceDoc(range.from - 1, range.from) === '{' &&
this.editor.state.sliceDoc(range.to, range.to + 1) === '}';
if (isBraceAutoinsertion) {
payload.selection = { anchor: range.from, head: range.to };
}
}
try {
this.editor?.dispatch(payload);
} catch (_) {
// ignore out-of-range selection error on drop
}
});
},
ndvInputData() {
this.editor?.dispatch({
@ -92,9 +77,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
mounted() {
const extensions = [
inputTheme({ isSingleLine: this.isSingleLine }),
n8nLanguageSupport(),
autocompletion(),
n8nLang(),
history(),
doubleBraceHandler(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.editable.of(!this.isReadOnly),
EditorView.domEventHandlers({

View file

@ -69,6 +69,357 @@ import { ICredentialsResponse } from '@/Interface';
let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null;
export function resolveParameter(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
opts: {
targetItem?: TargetItem;
inputNodeName?: string;
inputRunIndex?: number;
inputBranchIndex?: number;
} = {},
): IDataObject | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const inputName = 'main';
const activeNode = useNDVStore().activeNode;
const workflow = getCurrentWorkflow();
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode!.name, inputName, 1);
const executionData = useWorkflowsStore().getWorkflowExecution;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode!.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find(
(parentNodeName) => workflowRunData[parentNodeName],
);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
let _connectionInputData = connectionInputData(
parentNode,
activeNode!.name,
inputName,
runIndexParent,
nodeConnection,
);
let runExecutionData: IRunExecutionData;
if (executionData === null || !executionData.data) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: IPinData[string] | undefined =
useWorkflowsStore().pinDataByNodeName(parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [pinData.map((data) => ({ json: data }))],
},
},
],
},
},
};
}
});
if (_connectionInputData === null) {
_connectionInputData = [];
}
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (
opts?.targetItem === undefined &&
workflowRunData !== null &&
workflowRunData[activeNode!.name]
) {
runIndexCurrent = workflowRunData[activeNode!.name].length - 1;
}
const _executeData = executeData(parentNode, activeNode!.name, inputName, runIndexCurrent);
return workflow.expression.getParameterValue(
parameter,
runExecutionData,
runIndexCurrent,
itemIndex,
activeNode!.name,
_connectionInputData,
'manual',
useRootStore().timezone,
additionalKeys,
_executeData,
false,
) as IDataObject;
}
function getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = getNodes();
const connections = useWorkflowsStore().allConnections;
const cacheKey = JSON.stringify({ nodes, connections });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}
cachedWorkflowKey = cacheKey;
return getWorkflow(nodes, connections, copyData);
}
// Returns a shallow copy of the nodes which means that all the data on the lower
// levels still only gets referenced but the top level object is a different one.
// This has the advantage that it is very fast and does not cause problems with vuex
// when the workflow replaces the node-parameters.
function getNodes(): INodeUi[] {
const nodes = useWorkflowsStore().allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
}
// Returns a workflow instance.
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = getNodeTypes();
let workflowId: string | undefined = useWorkflowsStore().workflowId;
if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
const workflowName = useWorkflowsStore().workflowName;
cachedWorkflow = new Workflow({
id: workflowId,
name: workflowName,
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData ? deepCopy(connections) : connections,
active: false,
nodeTypes,
settings: useWorkflowsStore().workflowSettings,
// @ts-ignore
pinData: useWorkflowsStore().pinData,
});
return cachedWorkflow;
}
function getNodeTypes(): INodeTypes {
const nodeTypes: INodeTypes = {
nodeTypes: {},
init: async (nodeTypes?: INodeTypeData): Promise<void> => {},
// @ts-ignore
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = useNodeTypesStore().getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger:
(![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) &&
nodeTypeDescription.inputs.length === 0 &&
!nodeTypeDescription.webhooks) ||
undefined,
};
},
};
return nodeTypes;
}
// Returns connectionInputData to be able to execute an expression.
function connectionInputData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 },
): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const _executeData = executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (
!Object.keys(_executeData.data).length ||
_executeData.data[inputName].length <= nodeConnection.sourceIndex
) {
connectionInputData = [];
} else {
connectionInputData = _executeData.data![inputName][nodeConnection.sourceIndex];
if (connectionInputData !== null) {
// Update the pairedItem information on items
connectionInputData = connectionInputData.map((item, itemIndex) => {
return {
...item,
pairedItem: {
item: itemIndex,
input: nodeConnection.destinationIndex,
},
};
});
}
}
}
const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = useWorkflowsStore().pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
return connectionInputData;
}
function executeData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
): IExecuteData {
const executeData = {
node: {},
data: {},
source: null,
} as IExecuteData;
if (parentNode.length) {
// Add the input data to be able to also resolve the short expression format
// which does not use the node name
const parentNodeName = parentNode[0];
const parentPinData = useWorkflowsStore().getPinData![parentNodeName];
// populate `executeData` from `pinData`
if (parentPinData) {
executeData.data = { main: [parentPinData] };
executeData.source = { main: [{ previousNode: parentNodeName }] };
return executeData;
}
// populate `executeData` from `runData`
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
if (workflowRunData === null) {
return executeData;
}
if (
!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex] ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
workflowRunData[parentNodeName][runIndex].data === undefined ||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
) {
executeData.data = {};
} else {
executeData.data = workflowRunData[parentNodeName][runIndex].data!;
if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) {
executeData.source = {
[inputName]: workflowRunData[currentNode][runIndex].source!,
};
} else {
// The current node did not get executed in UI yet so build data manually
executeData.source = {
[inputName]: [
{
previousNode: parentNodeName,
},
],
};
}
}
}
return executeData;
}
export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({
computed: {
...mapStores(
@ -86,154 +437,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
},
},
methods: {
executeData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
): IExecuteData {
const executeData = {
node: {},
data: {},
source: null,
} as IExecuteData;
if (parentNode.length) {
// Add the input data to be able to also resolve the short expression format
// which does not use the node name
const parentNodeName = parentNode[0];
const parentPinData = this.workflowsStore.getPinData![parentNodeName];
// populate `executeData` from `pinData`
if (parentPinData) {
executeData.data = { main: [parentPinData] };
executeData.source = { main: [{ previousNode: parentNodeName }] };
return executeData;
}
// populate `executeData` from `runData`
const workflowRunData = this.workflowsStore.getWorkflowRunData;
if (workflowRunData === null) {
return executeData;
}
if (
!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex] ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
workflowRunData[parentNodeName][runIndex].data === undefined ||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
) {
executeData.data = {};
} else {
executeData.data = workflowRunData[parentNodeName][runIndex].data!;
if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) {
executeData.source = {
[inputName]: workflowRunData[currentNode][runIndex].source!,
};
} else {
// The current node did not get executed in UI yet so build data manually
executeData.source = {
[inputName]: [
{
previousNode: parentNodeName,
},
],
};
}
}
}
return executeData;
},
// Returns connectionInputData to be able to execute an expression.
connectionInputData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 },
): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const executeData = this.executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (
!Object.keys(executeData.data).length ||
executeData.data[inputName].length <= nodeConnection.sourceIndex
) {
connectionInputData = [];
} else {
connectionInputData = executeData.data![inputName][nodeConnection.sourceIndex];
if (connectionInputData !== null) {
// Update the pairedItem information on items
connectionInputData = connectionInputData.map((item, itemIndex) => {
return {
...item,
pairedItem: {
item: itemIndex,
input: nodeConnection.destinationIndex,
},
};
});
}
}
}
const parentPinData = parentNode.reduce(
(acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
},
[],
);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
return connectionInputData;
},
// Returns a shallow copy of the nodes which means that all the data on the lower
// levels still only gets referenced but the top level object is a different one.
// This has the advantage that it is very fast and does not cause problems with vuex
// when the workflow replaces the node-parameters.
getNodes(): INodeUi[] {
const nodes = this.workflowsStore.allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
},
resolveParameter,
getCurrentWorkflow,
getNodes,
getWorkflow,
getNodeTypes,
connectionInputData,
executeData,
// Returns data about nodeTypes which have a "maxNodes" limit set.
// For each such type does it return how high the limit is, how many
@ -347,70 +557,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
return workflowIssues;
},
getNodeTypes(): INodeTypes {
const nodeTypes: INodeTypes = {
nodeTypes: {},
init: async (nodeTypes?: INodeTypeData): Promise<void> => {},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = this.nodeTypesStore.getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger:
(![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) &&
nodeTypeDescription.inputs.length === 0 &&
!nodeTypeDescription.webhooks) ||
undefined,
};
},
};
return nodeTypes;
},
getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = this.getNodes();
const connections = this.workflowsStore.allConnections;
const cacheKey = JSON.stringify({ nodes, connections });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}
cachedWorkflowKey = cacheKey;
return this.getWorkflow(nodes, connections, copyData);
},
// Returns a workflow instance.
getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = this.getNodeTypes();
let workflowId: string | undefined = this.workflowsStore.workflowId;
if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
const workflowName = this.workflowsStore.workflowName;
cachedWorkflow = new Workflow({
id: workflowId,
name: workflowName,
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData ? deepCopy(connections) : connections,
active: false,
nodeTypes,
settings: this.workflowsStore.workflowSettings,
pinData: this.workflowsStore.pinData,
});
return cachedWorkflow;
},
// Returns the currently loaded workflow as JSON.
getWorkflowDataToSave(): Promise<IWorkflowData> {
const workflowNodes = this.workflowsStore.allNodes;
@ -579,143 +725,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath);
},
resolveParameter(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
opts: {
targetItem?: TargetItem;
inputNodeName?: string;
inputRunIndex?: number;
inputBranchIndex?: number;
} = {},
): IDataObject | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const inputName = 'main';
const activeNode = this.ndvStore.activeNode;
const workflow = this.getCurrentWorkflow();
const workflowRunData = this.workflowsStore.getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode?.name, inputName, 1);
const executionData = this.workflowsStore.getWorkflowExecution;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find(
(parentNodeName) => workflowRunData[parentNodeName],
);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
let connectionInputData = this.connectionInputData(
parentNode,
activeNode.name,
inputName,
runIndexParent,
nodeConnection,
);
let runExecutionData: IRunExecutionData;
if (executionData === null || !executionData.data) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: IPinData[string] = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [pinData.map((data) => ({ json: data }))],
},
},
],
},
},
};
}
});
if (connectionInputData === null) {
connectionInputData = [];
}
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (
opts?.targetItem === undefined &&
workflowRunData !== null &&
workflowRunData[activeNode.name]
) {
runIndexCurrent = workflowRunData[activeNode.name].length - 1;
}
const executeData = this.executeData(parentNode, activeNode.name, inputName, runIndexCurrent);
return workflow.expression.getParameterValue(
parameter,
runExecutionData,
runIndexCurrent,
itemIndex,
activeNode.name,
connectionInputData,
'manual',
this.rootStore.timezone,
additionalKeys,
executeData,
false,
) as IDataObject;
},
resolveExpression(
expression: string,
siblingParameters: INodeParameters = {},
@ -731,13 +740,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
__xxxxxxx__: expression,
...siblingParameters,
};
const returnData: IDataObject | null = this.resolveParameter(parameters, opts);
const returnData: IDataObject | null = resolveParameter(parameters, opts);
if (!returnData) {
return null;
}
if (typeof returnData['__xxxxxxx__'] === 'object') {
const workflow = this.getCurrentWorkflow();
const workflow = getCurrentWorkflow();
return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object);
}
return returnData['__xxxxxxx__'];
@ -980,7 +989,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
this.uiStore.stateIsDirty = false;
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
this.getCurrentWorkflow(true); // refresh cache
getCurrentWorkflow(true); // refresh cache
return true;
} catch (e) {
this.uiStore.removeActiveAction('workflowSaving');

View file

@ -0,0 +1,30 @@
import { alphaCompletions } from '../alpha.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
const EXPLICIT = false;
test('should return alphabetic char completion options: D', () => {
const doc = '{{ D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
if (!result) throw new Error('Expected D completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(['DateTime']);
expect(from).toEqual(position - 1);
});
test('should not return alphabetic char completion options: $input.D', () => {
const doc = '{{ $input.D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,37 @@
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { dateTimeOptions, nowTodayOptions, luxonCompletions } from '../luxon.completions';
const EXPLICIT = false;
test('should return luxon completion options: $now, $today', () => {
['$now', '$today'].forEach((luxonVar) => {
const doc = `{{ ${luxonVar}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error(`Expected luxon ${luxonVar} completion options`);
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(nowTodayOptions().map((o) => o.label));
expect(from).toEqual(position);
});
});
test('should return luxon completion options: DateTime', () => {
const doc = '{{ DateTime. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error('Expected luxon completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(dateTimeOptions().map((o) => o.label));
expect(from).toEqual(position);
});

View file

@ -0,0 +1,146 @@
import { proxyCompletions } from '../proxy.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import {
executionProxy,
inputProxy,
itemProxy,
nodeSelectorProxy,
prevNodeProxy,
workflowProxy,
} from './proxyMocks';
import { IDataObject } from 'n8n-workflow';
const EXPLICIT = false;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
function testCompletionOptions(proxy: IDataObject, toResolve: string) {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(proxy);
const doc = `{{ ${toResolve}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
if (!result) throw new Error(`Expected ${toResolve} completion options`);
const { options: actual, from } = result;
expect(actual.map((o) => o.label)).toEqual(Reflect.ownKeys(proxy));
expect(from).toEqual(position);
}
// input proxy
test('should return proxy completion options: $input', () => {
testCompletionOptions(inputProxy, '$input');
});
// item proxy
test('should return proxy completion options: $input.first()', () => {
testCompletionOptions(itemProxy, '$input.first()');
});
test('should return proxy completion options: $input.last()', () => {
testCompletionOptions(itemProxy, '$input.last()');
});
test('should return proxy completion options: $input.item', () => {
testCompletionOptions(itemProxy, '$input.item');
});
test('should return proxy completion options: $input.all()[0]', () => {
testCompletionOptions(itemProxy, '$input.all()[0]');
});
// json proxy
test('should return proxy completion options: $json', () => {
testCompletionOptions(workflowProxy, '$json');
});
// prevNode proxy
test('should return proxy completion options: $prevNode', () => {
testCompletionOptions(prevNodeProxy, '$prevNode');
});
// execution proxy
test('should return proxy completion options: $execution', () => {
testCompletionOptions(executionProxy, '$execution');
});
// workflow proxy
test('should return proxy completion options: $workflow', () => {
testCompletionOptions(workflowProxy, '$workflow');
});
// node selector proxy
test('should return proxy completion options: $()', () => {
const firstNodeName = 'Manual';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const connections = {
Manual: {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
};
const initialState = { workflows: { workflow: { nodes, connections } } };
setActivePinia(createTestingPinia({ initialState }));
testCompletionOptions(nodeSelectorProxy, "$('Set')");
});
// no proxy
test('should not return completion options for non-existing proxies', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null);
const doc = '{{ $hello. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,98 @@
export const inputProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
return undefined;
},
},
);
export const nodeSelectorProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params', 'pairedItem', 'itemMatching'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
if (property === 'pairedItem') return {};
if (property === 'itemMatching') return {};
return undefined;
},
},
);
export const itemProxy = new Proxy(
{ json: {}, pairedItem: {} },
{
get(_, property) {
if (property === 'json') return {};
return undefined;
},
},
);
export const prevNodeProxy = new Proxy(
{},
{
ownKeys() {
return ['name', 'outputIndex', 'runIndex'];
},
get(_, property) {
if (property === 'name') return '';
if (property === 'outputIndex') return 0;
if (property === 'runIndex') return 0;
return undefined;
},
},
);
export const executionProxy = new Proxy(
{},
{
ownKeys() {
return ['id', 'mode', 'resumeUrl'];
},
get(_, property) {
if (property === 'id') return '';
if (property === 'mode') return '';
if (property === 'resumeUrl') return '';
return undefined;
},
},
);
export const workflowProxy = new Proxy(
{},
{
ownKeys() {
return ['active', 'id', 'name'];
},
get(_, property) {
if (property === 'active') return false;
if (property === 'id') return '';
if (property === 'name') return '';
return undefined;
},
},
);

View file

@ -0,0 +1,80 @@
import { rootCompletions } from '../root.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { v4 as uuidv4 } from 'uuid';
import { i18n } from '@/plugins/i18n';
const EXPLICIT = false;
test('should return completion options: $', () => {
setActivePinia(createTestingPinia());
const doc = '{{ $ }}';
const position = doc.indexOf('$') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign completion options');
const { options, from } = result;
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
expect(options.map((o) => o.label)).toEqual(rootKeys);
expect(from).toEqual(position - 1);
});
test('should return completion options: $(', () => {
const firstNodeName = 'Manual Trigger';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const initialState = { workflows: { workflow: { nodes } } };
setActivePinia(createTestingPinia({ initialState }));
const doc = '{{ $( }}';
const position = doc.indexOf('(') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign-selector completion options');
const { options, from } = result;
expect(options).toHaveLength(nodes.length);
expect(options[0].label).toEqual(`$('${firstNodeName}')`);
expect(options[1].label).toEqual(`$('${secondNodeName}')`);
expect(from).toEqual(position - 2);
});
test('should not return completion options for regular strings', () => {
setActivePinia(createTestingPinia());
const doc = '{{ hello }}';
const position = doc.indexOf('o') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,50 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from alphabetic char, e.g. `D` -> `DateTime`.
*/
export function alphaCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(\s+)D[ateTim]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const userInput = word.text.trim();
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const emptyKeys = ['DateTime'];
return emptyKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
}

View file

@ -0,0 +1,84 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import { DateTime } from 'luxon';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
export function luxonCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(DateTime|\$(now|today)*)\.(\w|\.|\(|\))*/); //
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options = generateOptions(toResolve);
const userInputTail = word.text.split('.').pop();
if (userInputTail === undefined) return null;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string): Completion[] {
if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions();
if (toResolve === 'DateTime') return dateTimeOptions();
return [];
}
export const nowTodayOptions = () => {
const SKIP_SET = new Set(['constructor', 'get']);
const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
.filter(([key]) => !SKIP_SET.has(key))
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.luxonInstance[key];
if (info) option.info = info;
return option;
});
};
export const dateTimeOptions = () => {
const SKIP_SET = new Set(['prototype', 'name', 'length']);
const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP_SET.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b));
return keys.map((key) => {
const option: Completion = { label: `${key}()`, type: 'function' };
const info = i18n.luxonStatic[key];
if (info) option.info = info;
return option;
});
};

View file

@ -0,0 +1,106 @@
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { isAllowedInDotNotation, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject } from 'n8n-workflow';
import type { Word } from '@/types/completions';
/**
* Completions from proxies to their content.
*/
export function proxyCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(
/\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/,
);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options: Completion[] = [];
try {
const proxy = resolveParameter(`={{ ${toResolve} }}`);
if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null;
options = generateOptions(toResolve, proxy, word);
} catch (_) {
return null;
}
let userInputTail = '';
const delimiter = word.text.includes('json[') ? 'json[' : '.';
userInputTail = word.text.split(delimiter).pop() as string;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] {
const SKIP_SET = new Set(['__ob__']);
if (word.text.includes('json[')) {
return Object.keys(proxy.json as object)
.filter((key) => !SKIP_SET.has(key))
.map((key) => {
return {
label: `'${key}']`,
type: 'keyword',
};
});
}
const proxyName = toResolve.startsWith('$(') ? '$()' : toResolve;
return (Reflect.ownKeys(proxy) as string[])
.filter((key) => {
if (word.text.endsWith('json.')) return !SKIP_SET.has(key) && isAllowedInDotNotation(key);
return !SKIP_SET.has(key);
})
.map((key) => {
ensureKeyCanBeResolved(proxy, key);
const isFunction = typeof proxy[key] === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const infoKey = [proxyName, key].join('.');
const info = i18n.proxyVars[infoKey];
if (info) option.info = info;
return option;
});
}
function ensureKeyCanBeResolved(proxy: IDataObject, key: string) {
try {
proxy[key];
} catch (error) {
// e.g. attempting to access non-parent node with `$()`
throw new Error('Cannot generate options', { cause: error });
}
}

View file

@ -0,0 +1,60 @@
import { i18n } from '@/plugins/i18n';
import { autocompletableNodeNames, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from `$` to proxies.
*/
export function rootCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\$\w*[^.]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const { text: userInput } = word;
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
const options: Completion[] = rootKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
options.push(
...autocompletableNodeNames().map((nodeName) => ({
label: `$('${nodeName}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
return options;
}

View file

@ -0,0 +1,31 @@
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
import { useWorkflowsStore } from '@/stores/workflows';
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node) => node.name);
}
export const longestCommonPrefix = (strings: string[]) => {
if (strings.length === 0) return '';
return strings.reduce((acc, next) => {
let i = 0;
while (acc[i] && next[i] && acc[i] === next[i]) {
i++;
}
return acc.slice(0, i);
});
};
/**
* Whether a string may be used as a key in object dot notation access.
*/
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};

View file

@ -0,0 +1,49 @@
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens while autocompletion is active
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
from !== selection.from ||
to !== selection.to
) {
return false;
}
const transaction = insertBracket(view.state, insert);
if (!transaction) return false;
view.dispatch(transaction);
return true;
});
const [_, bracketState] = closeBrackets() as readonly Extension[];
/**
* CodeMirror plugin for code node editor:
*
* - prevent token autoclosing during autocompletion
* - prevent square bracket autoclosing prior to `.json`
*
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
*/
export const codeInputHandler = () => [handler, bracketState];

View file

@ -1,12 +1,23 @@
import { closeBrackets, insertBracket } from '@codemirror/autocomplete';
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens while autocompletion is active
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
@ -22,14 +33,10 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
view.dispatch(transaction);
/**
* Customizations to inject whitespace and braces for setup and completion
*/
// customization: inject whitespace and second brace for brace completion: {| } -> {{ | }}
const cursor = view.state.selection.main.head;
// inject whitespace and second brace for brace completion: {| } -> {{ | }}
const isBraceCompletion =
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
view.state.sliceDoc(cursor, cursor + 1) === '}';
@ -43,7 +50,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
return true;
}
// inject whitespace for brace setup: empty -> {| }
// customization: inject whitespace for brace setup: empty -> {| }
const isBraceSetup =
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
@ -55,7 +62,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
return true;
}
// inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
// customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
const [range] = view.state.selection.ranges;
@ -78,6 +85,12 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
const [_, bracketState] = closeBrackets() as readonly Extension[];
/**
* CodeMirror plugin to handle double braces `{{ }}` for resolvables in n8n expressions.
* CodeMirror plugin for (inline and modal) expression editor:
*
* - prevent token autoclosing during autocompletion (exception: `{`),
* - prevent square bracket autoclosing prior to `.json`
* - inject whitespace and braces for resolvables
*
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
*/
export const doubleBraceHandler = () => [inputHandler, bracketState];
export const expressionInputHandler = () => [handler, bracketState];

View file

@ -0,0 +1,33 @@
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { ifIn } from '@codemirror/autocomplete';
import { proxyCompletions } from './completions/proxy.completions';
import { rootCompletions } from './completions/root.completions';
import { luxonCompletions } from './completions/luxon.completions';
import { alphaCompletions } from './completions/alpha.completions';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' }
: null;
}),
});
const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
export function n8nLang() {
const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map(
(group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }),
);
return new LanguageSupport(n8nLanguage, [
n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }),
...options,
]);
}

View file

@ -1,20 +0,0 @@
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { parser as jsParser } from '@lezer/javascript';
const parserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: jsParser, overlay: (node) => node.type.name === 'Resolvable' }
: null;
}),
});
const n8nLanguage = LRLanguage.define({ parser: parserWithNestedJsParser });
export function n8nLanguageSupport() {
return new LanguageSupport(n8nLanguage);
}

View file

@ -1,33 +0,0 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
/**
* Completions available inside the resolvable segment `{{ ... }}` of an n8n expression.
*
* Currently unused.
*/
export function resolvableCompletions(context: CompletionContext): CompletionResult | null {
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
if (nodeBefore.name !== 'Resolvable') return null;
const pattern = /(?<quotedString>('|")\w*('|"))\./;
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const match = preCursor.text.match(pattern);
if (!match?.groups?.quotedString) return null;
const { quotedString } = match.groups;
return {
from: preCursor.from,
options: [
{ label: `${quotedString}.replace()`, info: 'Replace part of a string with another' },
{ label: `${quotedString}.slice()`, info: 'Copy part of a string' },
],
};
}

View file

@ -325,6 +325,153 @@ export class I18nClass {
},
};
}
rootVars: Record<string, string | undefined> = {
$binary: this.baseText('codeNodeEditor.completer.binary'),
$execution: this.baseText('codeNodeEditor.completer.$execution'),
$input: this.baseText('codeNodeEditor.completer.$input'),
'$jmespath()': this.baseText('codeNodeEditor.completer.$jmespath'),
$json: this.baseText('codeNodeEditor.completer.json'),
$itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'),
$now: this.baseText('codeNodeEditor.completer.$now'),
$prevNode: this.baseText('codeNodeEditor.completer.$prevNode'),
$runIndex: this.baseText('codeNodeEditor.completer.$runIndex'),
$today: this.baseText('codeNodeEditor.completer.$today'),
$workflow: this.baseText('codeNodeEditor.completer.$workflow'),
};
proxyVars: Record<string, string | undefined> = {
'$input.all': this.baseText('codeNodeEditor.completer.$input.all'),
'$input.first': this.baseText('codeNodeEditor.completer.$input.first'),
'$input.item': this.baseText('codeNodeEditor.completer.$input.item'),
'$input.last': this.baseText('codeNodeEditor.completer.$input.last'),
'$().all': this.baseText('codeNodeEditor.completer.selector.all'),
'$().context': this.baseText('codeNodeEditor.completer.selector.context'),
'$().first': this.baseText('codeNodeEditor.completer.selector.first'),
'$().item': this.baseText('codeNodeEditor.completer.selector.item'),
'$().itemMatching': this.baseText('codeNodeEditor.completer.selector.itemMatching'),
'$().last': this.baseText('codeNodeEditor.completer.selector.last'),
'$().params': this.baseText('codeNodeEditor.completer.selector.params'),
'$prevNode.name': this.baseText('codeNodeEditor.completer.$prevNode.name'),
'$prevNode.outputIndex': this.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),
'$prevNode.runIndex': this.baseText('codeNodeEditor.completer.$prevNode.runIndex'),
'$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
'$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'),
'$execution.resumeUrl': this.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
'$workflow.active': this.baseText('codeNodeEditor.completer.$workflow.active'),
'$workflow.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
'$workflow.name': this.baseText('codeNodeEditor.completer.$workflow.name'),
};
luxonInstance: Record<string, string | undefined> = {
// getters
isValid: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'),
invalidReason: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.invalidReason'),
invalidExplanation: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation',
),
locale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'),
numberingSystem: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem',
),
outputCalendar: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.outputCalendar'),
zone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'),
zoneName: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'),
year: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
quarter: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'),
month: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
day: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
hour: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
minute: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
second: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
millisecond: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.millisecond'),
weekYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'),
weekNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekNumber'),
weekday: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'),
ordinal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'),
monthShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthShort'),
monthLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthLong'),
weekdayShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayShort'),
weekdayLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayLong'),
offset: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'),
offsetNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNumber'),
offsetNameShort: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort',
),
offsetNameLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong'),
isOffsetFixed: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed'),
isInDST: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'),
isInLeapYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear'),
daysInMonth: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInMonth'),
daysInYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInYear'),
weeksInWeekYear: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear',
),
// methods
toUTC: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'),
toLocal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'),
setZone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'),
setLocale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setLocale'),
set: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'),
plus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'),
minus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'),
startOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'),
endOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'),
toFormat: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'),
toLocaleString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleString'),
toLocaleParts: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts'),
toISO: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'),
toISODate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISODate'),
toISOWeekDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate'),
toISOTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOTime'),
toRFC2822: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRFC2822'),
toHTTP: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'),
toSQLDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLDate'),
toSQLTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLTime'),
toSQL: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'),
toString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'),
valueOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'),
toMillis: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'),
toSeconds: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSeconds'),
toUnixInteger: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger'),
toJSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'),
toBSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'),
toObject: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'),
toJsDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'),
diff: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'),
diffNow: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'),
until: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'),
hasSame: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'),
equals: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'),
toRelative: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRelative'),
toRelativeCalendar: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar',
),
min: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'),
max: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'),
};
luxonStatic: Record<string, string | undefined> = {
now: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
local: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
utc: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
fromJSDate: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate'),
fromMillis: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis'),
fromSeconds: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds'),
fromObject: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject'),
fromISO: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO'),
fromRFC2822: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822'),
fromHTTP: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP'),
fromFormat: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat'),
fromSQL: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL'),
invalid: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid'),
isDateTime: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime'),
};
}
export const i18nInstance = new VueI18n({

View file

@ -0,0 +1 @@
export type Word = { from: number; to: number; text: string };

View file

@ -255,7 +255,7 @@ export class Expression {
const returnValue = this.renderExpression(parameterValue, data);
if (typeof returnValue === 'function') {
if (returnValue.name === '$') throw new Error('invalid syntax');
throw new Error(`${returnValue.name} is a function. Please add ()`);
throw new Error('This is a function. Please add ()');
} else if (typeof returnValue === 'string') {
return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') {

View file

@ -130,7 +130,7 @@ export class WorkflowDataProxy {
return {}; // incoming connection has pinned data, so stub context object
}
if (!that.runExecutionData?.executionData) {
if (!that.runExecutionData?.executionData && !that.runExecutionData?.resultData) {
throw new ExpressionError(
"The workflow hasn't been executed yet, so you can't reference any context data",
{
@ -931,6 +931,18 @@ export class WorkflowDataProxy {
return new Proxy(
{},
{
ownKeys(target) {
return [
'pairedItem',
'itemMatching',
'item',
'first',
'last',
'all',
'context',
'params',
];
},
get(target, property, receiver) {
if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
const pairedItemMethod = (itemIndex?: number) => {

View file

@ -509,9 +509,9 @@ importers:
packages/editor-ui:
specifiers:
'@codemirror/autocomplete': ^6.1.0
'@codemirror/autocomplete': ^6.4.0
'@codemirror/commands': ^6.1.0
'@codemirror/lang-javascript': ^6.0.2
'@codemirror/lang-javascript': ^6.1.2
'@codemirror/language': ^6.2.1
'@codemirror/lint': ^6.0.0
'@codemirror/state': ^6.1.4
@ -586,9 +586,9 @@ importers:
vue2-touch-events: ^3.2.1
xss: ^1.0.10
dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/commands': 6.1.2
'@codemirror/lang-javascript': 6.1.0
'@codemirror/lang-javascript': 6.1.2
'@codemirror/language': 6.2.1
'@codemirror/lint': 6.0.0
'@codemirror/state': 6.1.4
@ -2665,8 +2665,8 @@ packages:
minimist: 1.2.7
dev: true
/@codemirror/autocomplete/6.3.0_wo7q3lvweq5evsu423o7qzum5i:
resolution: {integrity: sha512-4jEvh3AjJZTDKazd10J6ZsCIqaYxDMCeua5ouQxY8hlFIml+nr7le0SgBhT3SIytFBmdzPK3AUhXGuW3T79nVg==}
/@codemirror/autocomplete/6.4.0_wo7q3lvweq5evsu423o7qzum5i:
resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
@ -2688,10 +2688,10 @@ packages:
'@lezer/common': 1.0.1
dev: false
/@codemirror/lang-javascript/6.1.0:
resolution: {integrity: sha512-wAWEY1Wdis2cKDy9A5q/rUmzLHFbZgoupJBcGaeMMsDPi68Rm90NsmzAEODE5kW8mYdRKFhQ157WJghOZ3yYdg==}
/@codemirror/lang-javascript/6.1.2:
resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==}
dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1
'@codemirror/lint': 6.0.0
'@codemirror/state': 6.1.4
@ -9031,7 +9031,7 @@ packages:
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1
'@lezer/highlight': 1.1.1
'@lezer/lr': 1.2.3