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
30 changed files with 1391 additions and 520 deletions

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' },
],
};
}