feat(Code Node): create Code node (#3965)

* Introduce node deprecation (#3930)

 Introduce node deprecation

* 🚧 Scaffold out Code node

* 👕 Fix lint

* 📘 Create types file

* 🚚 Rename theme

* 🔥 Remove unneeded prop

*  Override keybindings

*  Expand lintings

*  Create editor content getter

* 🚚 Ensure all helpers use `$`

*  Add autocompletion

*  Filter out welcome note node

*  Convey error line number

*  Highlight error line

*  Restore logging from node

*  More autocompletions

*  Streamline completions

* ✏️ Update placeholders

*  Update linter to new methods

* 🔥 Remove `$nodeItem` completions

*  Re-update placeholders

* 🎨 Fix formatting

* 📦 Update `package-lock.json`

*  Refresh with multi-line empty string

*  Account for syntax errors

* 🔥 Remove unneeded variant

*  Minor improvements

*  Add more autocompletions

* 🚚 Rename extension

* 🔥 Remove outdated comments

* 🚚 Rename field

*  More autocompletions

*  Fix up error display when empty text

* 🔥 Remove logging

*  More error validation

* 🐛 Fix `pairedItem` to `pairedItem()`

*  Add item to validation info

* 📦 Update `package-lock.json`

*  Leftover fixes

*  Set `insertNewlineAndIndent`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

* 👕 Add lint exception

* 📘 Add type to mixin type

* Clean up comment

*  Refactor completion per new requirements

*  Adjust placeholders

*  Add `json` autocompletions for `$input`

* 🎨 Set border

*  Restore local completion source

*  Implement autocompletion for imports

*  Add `.*` to follow user typing on autocompletion

* 📘 Fix typings in autocompletions

* 👕 Add linting for use of `item()`

* 📦 Update `package-lock.json`

* 🐛 Fix for `$items(nodeName)[0]`

*  Filter down built-in modules list

*  Refactor error handling

*  Linter and validation improvements

*  Apply review feedback

* ♻️ More general refactorings

*  Add dot notation utility

* Customize input handler

*  Support `.json.` completions

*  Adjust placeholder

*  Sort imports

* 🔥 Remove blank rows addition

*  Add more error validation

* 📦 Update `package-lock.json`

*  Make date logging consistent

* 🔧 Adjust linting highlight range

*  Add line numbers to each item mode errors

*  Allow for links in error descriptions

*  More input validation

*  Expand linting to loops

*  Deprecate Function and Function Item nodes

* 🐛 Fix placeholder syntax

* 📘 Narrow down type

* 🚚 Rename using kebab-case

* 🔥 Remove `mapGetters`

* ✏️ Fix casing

*  Adjust import for type

* ✏️ Fix quotes

* 🐛 Fix `activeNode` reference

*  Use constant

* 🔥 Remove logging

* ✏️ Fix typo

*  Add missing `notice`

* ✏️ Add tags

* ✏️ Fix alias

* ✏️ Update copy

* 🔥 Remove wrong linting

* ✏️ Update copy

*  Add validation for `null`

*  Add validation for non-object and non-array

*  Add validation for non-array with json

* ✏️ Intentionally use wrong spelling

*  More validation

* ✏️ More copy updates

* ✏️ Placeholder updates

*  Restore spelling

*  Fix var name

* ✏️ More copy updates

*  Add luxon autocompletions

*  Make scrollable

*  Fix comma from merge conflict resolution

* 📦 Update `package-lock.json`

* 👕 Fix lint detail

* 🎨 Set font family

*  Bring in expressions fix

* ♻️ Address feedback

*  Exclude codemirror packages from render chunks

* 🐛 Fix placeholder not showing on first load

* feat(editor-ui): Replace `lezer` with `esprima` in client linter (#4192)

* 🔥 Remove addition from misresolved conflict

*  Replace `lezer` with `esprima` in client linter

*  Add missing key

* 📦 Update `package-lock.json`

*  Match dependencies

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Match whitespace

* 🐛 Fix selection

*  Expand validation

* 🔥 Remove validation

* ✏️ Update copy

* 🚚 Move to constants

*  More `null` validation

*  Support `all()` with index to access item

*  Gloss over n8n syntax error

* 🎨 Re-style diagnostic button

* 🔥 Remove `item` as `itemAlias`

*  Add linting for `item.json` in single item mode

*  Refactor to add label info descriptions

*  More autocompletions

* 👕 Fix lint

*  Simplify typings

* feat(nodes-base): Multiline autocompletion for `code-node-editor` (#4220)

*  Simplify typings

*  Consolidate helpers in utils

*  Multiline autocompletion for standalone vars

* 🔥 Remove unneeded mixins

* ✏️ Update copy

* ✏️ Prep TODOs

*  Multiline completion for `$input.method` + `$input.item`

* 🔥 Remove unused method

* 🔥 Remove another unused method

* 🚚 Move luxon strings to helpers

*  Multiline autocompletion for methods output

*  Refactor to use optional chaining

* 👕 Fix lint

* ✏️ Update TODOs

*  Multiline autocompletion for `json` fields

* 📘 Add typings

*  De-duplicate callback to forEach

* 🐛 Fix autocompletions not working with leading whitespace

* 🌐 Apply i18n

* 👕 Fix lint

* :constructor: Second-period var usage completions

* 👕 Fix lint

* 👕 Add exception

*  Add completion telemetry

* 📘 Add typing

*  Major refactoring to organize

* 🐛 Fix multiline `.all()[index]`

* 🐛 Do not autoclose square brackets prior to `.json`

* 🐛 Fix accessor for multiline `jsonField` completions

*  Add completions for half-assignments

* 🐛 Fix `jsonField` completions for `x.json`

* ✏️ Improve comments

* 🐛 Fix `.json[field]` for multiline matches

*  Cleanup

* 📦 Update `package-lock.json`

* 👕 Fix lint

* 🐛 Rely on original value for custom matcher

*  Create `customMatcherJsonFieldCompletions` to simplify setup

* 🐛 Include selector in `customMatcherJsonField` completions

* ✏️ Make naming consistent

* ✏️ Add docline

*  Finish self-review cleanup

* 🔥 Remove outdated comment

* 📌 Pin luxon to major-minor

* ✏️ Fix typo

* 📦 Update `package-lock.json`

* 📦 Update `package-lock.json`

* 📦 Re-update `package-lock.json`

*  Add `luxon` for Gmail node

* 📦 Update `package-lock.json`

*  Replace Function with Code in suggested nodes

* 🐛 Fix `$prevNode` completions

* ✏️ Update `$execution.mode` copy

*  Separate luxon getters from methods

*  Adjusting linter to tolerate `.binary`

*  Adjust top-level item keys check

*  Anticipate user expecting `item` to pre-exist

*  Add linting for legacy item access

*  Add hint for attempted `items` access

*  Add keybinding for toggling comments

* ✏️ Update copy of `all`, `first`, `last` and `itemMatching`

* 🐛 Make `input.all()` etc act on copies

* 📦 Update `package-lock.json`

* 🐛 Fix guard in `$input.last()`

* ♻️ Address Jan's feedback

* ⬆️ Upgrade `eslint-plugin-n8n-nodes-base`

* 📦 Update `package-lock.json`

* 🔥 Remove unneeded exceptions

*  Restore placeholder logic

*  Add placeholders to client

*  Account for shadow item

* ✏️ More completion info labels

* 👕 Fix lint

* ✏️ Update copy

* ✏️ Update copy

* ✏️ More copy updates

* 📦 Update `package-lock.json`

*  Add more validation

*  Add placheolder on first load

* Replace `Cmd` with `Mod`

* 📦 Update `package-lock.json`
This commit is contained in:
Iván Ovejero
2022-10-13 14:28:02 +02:00
committed by GitHub
parent 12e821528b
commit 1db4fa2bf8
54 changed files with 5127 additions and 1400 deletions

View File

@@ -0,0 +1,174 @@
<template>
<div ref="codeNodeEditor" class="ph-no-capture" />
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, ViewUpdate } from '@codemirror/view';
import { javascript } from '@codemirror/lang-javascript';
import { baseExtensions } from './baseExtensions';
import { linterExtension } from './linter';
import { completerExtension } from './completer';
import { CODE_NODE_EDITOR_THEME } from './theme';
import { workflowHelpers } from '../mixins/workflowHelpers'; // for json field completions
import { codeNodeEditorEventBus } from '@/event-bus/code-node-editor-event-bus';
import { CODE_NODE_TYPE } from '@/constants';
import { ALL_ITEMS_PLACEHOLDER, EACH_ITEM_PLACEHOLDER } from './constants';
export default mixins(linterExtension, completerExtension, workflowHelpers).extend({
name: 'code-node-editor',
props: {
mode: {
type: String,
validator: (value: string): boolean =>
['runOnceForAllItems', 'runOnceForEachItem'].includes(value),
},
isReadOnly: {
type: Boolean,
default: false,
},
jsCode: {
type: String,
},
},
data() {
return {
editor: null as EditorView | null,
linterCompartment: new Compartment(),
};
},
watch: {
mode() {
this.reloadLinter();
this.refreshPlaceholder();
},
},
computed: {
content(): string {
if (!this.editor) return '';
return this.editor.state.doc.toString();
},
placeholder(): string {
return {
runOnceForAllItems: ALL_ITEMS_PLACEHOLDER,
runOnceForEachItem: EACH_ITEM_PLACEHOLDER,
}[this.mode];
},
previousPlaceholder(): string {
return {
runOnceForAllItems: EACH_ITEM_PLACEHOLDER,
runOnceForEachItem: ALL_ITEMS_PLACEHOLDER,
}[this.mode];
},
},
methods: {
reloadLinter() {
if (!this.editor) return;
this.editor.dispatch({
effects: this.linterCompartment.reconfigure(this.linterExtension()),
});
},
refreshPlaceholder() {
if (!this.editor) return;
if (!this.content.trim() || this.content.trim() === this.previousPlaceholder) {
this.editor.dispatch({
changes: { from: 0, to: this.content.length, insert: this.placeholder },
});
}
},
highlightLine(line: number | 'final') {
if (!this.editor) return;
if (line === 'final') {
this.editor.dispatch({
selection: { anchor: this.content.trim().length },
});
return;
}
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.line(line).from },
});
},
trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
if (!completionTx) return;
try {
// @ts-ignore - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0];
const full = this.content.slice(fromA, toB);
const lastDotIndex = full.lastIndexOf('.');
let context = null;
let insertedText = null;
if (lastDotIndex === -1) {
context = '';
insertedText = full;
} else {
context = full.slice(0, lastDotIndex);
insertedText = full.slice(lastDotIndex + 1);
}
this.$telemetry.track('User autocompleted code', {
instance_id: this.$store.getters.instanceId,
node_type: CODE_NODE_TYPE,
field_name: this.mode === 'runOnceForAllItems' ? 'jsCodeAllItems' : 'jsCodeEachItem',
field_type: 'code',
context,
inserted_text: insertedText,
});
} catch (_) {}
},
},
destroyed() {
codeNodeEditorEventBus.$off('error-line-number', this.highlightLine);
},
mounted() {
codeNodeEditorEventBus.$on('error-line-number', this.highlightLine);
const stateBasedExtensions = [
this.linterCompartment.of(this.linterExtension()),
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged) return;
this.trackCompletion(viewUpdate);
this.$emit('valueChanged', this.content);
}),
];
// empty on first load, default param value
if (this.jsCode === '') {
this.$emit('valueChanged', this.placeholder);
}
const state = EditorState.create({
doc: this.jsCode === '' ? this.placeholder : this.jsCode,
extensions: [
...baseExtensions,
...stateBasedExtensions,
CODE_NODE_EDITOR_THEME,
javascript(),
this.autocompletionExtension(),
],
});
this.editor = new EditorView({
parent: this.$refs.codeNodeEditor as HTMLDivElement,
state,
});
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,40 @@
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete';
import { history, indentWithTab, insertNewlineAndIndent, 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[];
export const baseExtensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
lintGutter(),
[customInputHandler, bracketState],
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
keymap.of([
{ key: 'Enter', run: insertNewlineAndIndent },
{ key: 'Tab', run: acceptCompletion },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Mod-/', run: toggleComment },
indentWithTab,
]),
EditorView.lineWrapping,
];

View File

@@ -0,0 +1,274 @@
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { autocompletion } from '@codemirror/autocomplete';
import { localCompletionSource } from '@codemirror/lang-javascript';
import { baseCompletions } from './completions/base.completions';
import { jsSnippets } from './completions/js.snippets';
import { requireCompletions } from './completions/require.completions';
import { executionCompletions } from './completions/execution.completions';
import { workflowCompletions } from './completions/workflow.completions';
import { prevNodeCompletions } from './completions/prevNode.completions';
import { luxonCompletions } from './completions/luxon.completions';
import { itemIndexCompletions } from './completions/itemIndex.completions';
import { itemFieldCompletions } from './completions/itemField.completions';
import { jsonFieldCompletions } from './completions/jsonField.completions';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Extension } from '@codemirror/state';
import type { CodeNodeEditorMixin } from './types';
export const completerExtension = mixins(
Vue as CodeNodeEditorMixin,
baseCompletions,
requireCompletions,
executionCompletions,
workflowCompletions,
prevNodeCompletions,
luxonCompletions,
itemIndexCompletions,
itemFieldCompletions,
jsonFieldCompletions,
).extend({
methods: {
autocompletionExtension(): Extension {
return autocompletion({
compareCompletions: (a: Completion, b: Completion) => {
if (/\.json$|id$|id['"]\]$/.test(a.label)) return 0;
return a.label.localeCompare(b.label);
},
override: [
jsSnippets,
localCompletionSource,
// core
this.baseCompletions,
this.requireCompletions,
this.nodeSelectorCompletions,
this.prevNodeCompletions,
this.workflowCompletions,
this.executionCompletions,
// luxon
this.todayCompletions,
this.nowCompletions,
this.dateTimeCompltions,
// item index
this.inputCompletions,
this.selectorCompletions,
// item field
this.inputMethodCompletions,
this.selectorMethodCompletions,
// item json field
this.inputJsonFieldCompletions,
this.selectorJsonFieldCompletions,
// multiline
this.multilineCompletions,
],
});
},
/**
* Complete uses of variables to any of the supported completions.
*/
multilineCompletions(context: CompletionContext): CompletionResult | null {
if (!this.editor) return null;
let variablesToValues: Record<string, string> = {};
try {
variablesToValues = this.variablesToValues();
} catch (_) {
return null;
}
if (Object.keys(variablesToValues).length === 0) return null;
/**
* Complete uses of extended variables, i.e. variables having
* one or more dotted segments already.
*
* const x = $input;
* x.first(). -> .json
* x.first().json. -> .field
*/
const docLines = this.editor.state.doc.toString().split('\n');
const varNames = Object.keys(variablesToValues);
const uses = this.extendedUses(docLines, varNames);
for (const use of uses.itemField) {
const matcher = use.replace(/\.$/, '');
const completions = this.matcherItemFieldCompletions(context, matcher, variablesToValues);
if (completions) return completions;
}
for (const use of uses.jsonField) {
const matcher = use.replace(/(\.|\[)$/, '');
const completions = this.matcherJsonFieldCompletions(context, matcher, variablesToValues);
if (completions) return completions;
}
/**
* Complete uses of unextended variables, i.e. variables having
* no dotted segment already.
*
* const x = $input;
* x. -> .first()
*
* const x = $input.first();
* x. -> .json
*
* const x = $input.first().json;
* x. -> .field
*/
const SELECTOR_REGEX = /^\$\((?<quotedNodeName>['"][\w\s]+['"])\)$/; // $('nodeName')
const INPUT_METHOD_REGEXES = Object.values({
first: /\$input\.first\(\)$/,
last: /\$input\.last\(\)$/,
item: /\$input\.item$/,
all: /\$input\.all\(\)\[(?<index>\w+)\]$/,
});
const SELECTOR_METHOD_REGEXES = Object.values({
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)$/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)$/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item$/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]$/,
});
const INPUT_JSON_REGEXES = Object.values({
first: /\$input\.first\(\)\.json$/,
last: /\$input\.last\(\)\.json$/,
item: /\$input\.item\.json$/,
all: /\$input\.all\(\)\[(?<index>\w+)\]\.json$/,
});
const SELECTOR_JSON_REGEXES = Object.values({
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json$/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json$/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json$/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\.json$/,
});
for (const [variable, value] of Object.entries(variablesToValues)) {
// core
if (value === '$execution') return this.executionCompletions(context, variable);
if (value === '$workflow') return this.workflowCompletions(context, variable);
if (value === '$prevNode') return this.prevNodeCompletions(context, variable);
// luxon
if (value === '$now') return this.nowCompletions(context, variable);
if (value === '$today') return this.todayCompletions(context, variable);
if (value === 'DateTime') return this.dateTimeCompltions(context, variable);
// item index
if (value === '$input') return this.inputCompletions(context, variable);
if (SELECTOR_REGEX.test(value)) return this.selectorCompletions(context, variable);
// json field
const inputJsonMatched = INPUT_JSON_REGEXES.some((regex) => regex.test(value));
const selectorJsonMatched = SELECTOR_JSON_REGEXES.some((regex) => regex.test(value));
if (inputJsonMatched || selectorJsonMatched) {
return this.matcherJsonFieldCompletions(context, variable, variablesToValues);
}
// item field
const inputMethodMatched = INPUT_METHOD_REGEXES.some((regex) => regex.test(value));
const selectorMethodMatched = SELECTOR_METHOD_REGEXES.some((regex) => regex.test(value));
if (inputMethodMatched || selectorMethodMatched) {
return this.matcherItemFieldCompletions(context, variable, variablesToValues);
}
}
return null;
},
// ----------------------------------
// helpers
// ----------------------------------
/**
* Create a map of variables and the values they point to.
*/
variablesToValues() {
return this.variableDeclarationLines().reduce<Record<string, string>>((acc, line) => {
const [left, right] = line.split('=');
const varName = left.replace(/(var|let|const)/, '').trim();
const varValue = right.replace(/;/, '').trim();
acc[varName] = varValue;
return acc;
}, {});
},
variableDeclarationLines() {
if (!this.editor) return [];
const docLines = this.editor.state.doc.toString().split('\n');
const isVariableDeclarationLine = (line: string) =>
['var', 'const', 'let'].some((varType) => line.startsWith(varType));
return docLines.filter(isVariableDeclarationLine);
},
/**
* Collect uses of variables pointing to n8n syntax if they have been extended.
*
* x.first().
* x.first().json.
* x.json.
*/
extendedUses(docLines: string[], varNames: string[]) {
return docLines.reduce<{ itemField: string[]; jsonField: string[] }>(
(acc, cur) => {
varNames.forEach((varName) => {
const accessorPattern = `(${varName}.first\\(\\)|${varName}.last\\(\\)|${varName}.item|${varName}.all\\(\\)\\[\\w+\\]).*`;
const methodMatch = cur.match(new RegExp(accessorPattern));
if (methodMatch) {
if (/json(\.|\[)$/.test(methodMatch[0])) {
acc.jsonField.push(methodMatch[0]);
} else {
acc.itemField.push(methodMatch[0]);
}
}
const jsonPattern = `^${varName}\\.json(\\.|\\[)$`;
const jsonMatch = cur.match(new RegExp(jsonPattern));
if (jsonMatch) {
acc.jsonField.push(jsonMatch[0]);
}
});
return acc;
},
{ itemField: [], jsonField: [] },
);
},
},
});

View File

@@ -0,0 +1,117 @@
import Vue from 'vue';
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '../constants';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { INodeUi } from '@/Interface';
import type { CodeNodeEditorMixin } from '../types';
function getAutocompletableNodeNames(nodes: INodeUi[]) {
return nodes
.filter((node: INodeUi) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node: INodeUi) => node.name);
}
export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `$` to `$execution $input $prevNode $runIndex $workflow $now $today
* $jmespath $('nodeName')` in both modes.
* - Complete `$` to `$json $binary $itemIndex` in single-item mode.
*/
baseCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/\$\w*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES: Completion[] = [
{
label: '$execution',
info: this.$locale.baseText('codeNodeEditor.completer.$execution'),
},
{ label: '$input', info: this.$locale.baseText('codeNodeEditor.completer.$input') },
{
label: '$prevNode',
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode'),
},
{
label: '$workflow',
info: this.$locale.baseText('codeNodeEditor.completer.$workflow'),
},
{
label: '$now',
info: this.$locale.baseText('codeNodeEditor.completer.$now'),
},
{
label: '$today',
info: this.$locale.baseText('codeNodeEditor.completer.$today'),
},
{
label: '$jmespath()',
info: this.$locale.baseText('codeNodeEditor.completer.$jmespath'),
},
{
label: '$runIndex',
info: this.$locale.baseText('codeNodeEditor.completer.$runIndex'),
},
];
const options: Completion[] = TOP_LEVEL_COMPLETIONS_IN_BOTH_MODES.map(addVarType);
options.push(
...getAutocompletableNodeNames(this.$store.getters.allNodes).map((nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
}),
);
if (this.mode === 'runOnceForEachItem') {
const TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE = [
{ label: '$json' },
{ label: '$binary' },
{
label: '$itemIndex',
info: this.$locale.baseText('codeNodeEditor.completer.$itemIndex'),
},
];
options.push(...TOP_LEVEL_COMPLETIONS_IN_SINGLE_ITEM_MODE.map(addVarType));
}
return {
from: preCursor.from,
options,
};
},
/**
* Complete `$(` to `$('nodeName')`.
*/
nodeSelectorCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/\$\(.*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = getAutocompletableNodeNames(this.$store.getters.allNodes).map(
(nodeName) => {
return {
label: `$('${nodeName}')`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$()', {
interpolate: { nodeName },
}),
};
},
);
return {
from: preCursor.from,
options,
};
},
},
});

View File

@@ -0,0 +1,42 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$execution.` to `.id .mode .resumeUrl`
*/
executionCompletions(
context: CompletionContext,
matcher = '$execution',
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.id`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.id'),
},
{
label: `${matcher}.mode`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.mode'),
},
{
label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View File

@@ -0,0 +1,169 @@
import Vue from 'vue';
import { addVarType, escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `x.first().` to `.json .binary`
* - Complete `x.last().` to `.json .binary`
* - Complete `x.all()[index].` to `.json .binary`
* - Complete `x.item.` to `.json .binary`.
*/
matcherItemFieldCompletions(
context: CompletionContext,
matcher: string,
variablesToValues: Record<string, string>,
) {
const preCursor = context.matchBefore(new RegExp(`${escape(matcher)}\..*`));
if (!preCursor) return null;
const [varName] = preCursor.text.split('.');
const originalValue = variablesToValues[varName];
if (!originalValue) return null;
const options: Completion[] = [
{
label: `${matcher}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${matcher}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
/**
* - Complete `$input.first().` to `.json .binary`.
* - Complete `$input.last().` to `.json .binary`.
* - Complete `$input.all()[index].` to `.json .binary`.
* - Complete `$input.item.` to `.json .binary`.
*/
inputMethodCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$input\.first\(\)\..*/,
last: /\$input\.last\(\)\..*/,
item: /\$input\.item\..*/,
all: /\$input\.all\(\)\[(?<index>\w+)\]\..*/,
};
for (const [name, regex] of Object.entries(patterns)) {
const preCursor = context.matchBefore(regex);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
let replacementBase = '';
if (name === 'item') replacementBase = '$input.item';
if (name === 'first') replacementBase = '$input.first()';
if (name === 'last') replacementBase = '$input.last()';
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
replacementBase = `$input.all()[${index}]`;
}
const options: Completion[] = [
{
label: `${replacementBase}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${replacementBase}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
}
return null;
},
/**
* - Complete `$('nodeName').first().` to `.json .binary`.
* - Complete `$('nodeName').last().` to `.json .binary`.
* - Complete `$('nodeName').all()[index].` to `.json .binary`.
* - Complete `$('nodeName').item.` to `.json .binary`.
*/
selectorMethodCompletions(
context: CompletionContext,
matcher: string | null = null,
): CompletionResult | null {
const patterns = {
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\..*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\..*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\..*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\..*/,
};
for (const [name, regex] of Object.entries(patterns)) {
const preCursor = context.matchBefore(regex);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
const match = preCursor.text.match(regex);
let start = '';
if (!matcher && match?.groups?.quotedNodeName) {
start = `$(${match.groups.quotedNodeName})`;
}
let replacementBase = '';
if (name === 'item') replacementBase = `${start}.item`;
if (name === 'first') replacementBase = `${start}.first()`;
if (name === 'last') replacementBase = `${start}.last()`;
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
replacementBase = `${start}.all()[${match.groups.index}]`;
}
const options: Completion[] = [
{
label: `${replacementBase}.json`,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
},
{
label: `${replacementBase}.binary`,
info: this.$locale.baseText('codeNodeEditor.completer.binary'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
}
return null;
},
},
});

View File

@@ -0,0 +1,137 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const itemIndexCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `$input.` to `.first() .last() .all() .itemMatching()` in all-items mode.
* - Complete `$input.` to `.item` in single-item mode.
*/
inputCompletions(context: CompletionContext, matcher = '$input'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [];
if (this.mode === 'runOnceForAllItems') {
options.push(
{
label: `${matcher}.first()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.first'),
},
{
label: `${matcher}.last()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.last'),
},
{
label: `${matcher}.all()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.all'),
},
{
label: `${matcher}.itemMatching()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.itemMatching'),
},
);
}
if (this.mode === 'runOnceForEachItem') {
options.push({
label: `${matcher}.item`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.$input.item'),
});
}
return {
from: preCursor.from,
options,
};
},
/**
* - Complete `$('nodeName').` to `.params .context` in both modes.
* - Complete `$('nodeName').` to `.first() .last() .all() .itemMatching()` in all-items mode.
* - Complete `$('nodeName').` to `.item` in single-item mode.
*/
selectorCompletions(context: CompletionContext, matcher: string | null = null) {
const pattern =
matcher === null
? /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\..*/ // $('nodeName').
: new RegExp(`${matcher}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const match = preCursor.text.match(pattern);
let replacementBase = '';
if (matcher === null && match?.groups?.quotedNodeName) {
replacementBase = `$(${match.groups.quotedNodeName})`;
} else if (matcher) {
replacementBase = matcher;
}
const options: Completion[] = [
{
label: `${replacementBase}.params`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.params'),
},
{
label: `${replacementBase}.context`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.context'),
},
];
if (this.mode === 'runOnceForAllItems') {
options.push(
{
label: `${replacementBase}.first()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.first'),
},
{
label: `${replacementBase}.last()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.last'),
},
{
label: `${replacementBase}.all()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.$input.all'),
},
{
label: `${replacementBase}.itemMatching()`,
type: 'function',
info: this.$locale.baseText('codeNodeEditor.completer.selector.itemMatching'),
},
);
}
if (this.mode === 'runOnceForEachItem') {
options.push({
label: `${replacementBase}.item`,
type: 'variable',
info: this.$locale.baseText('codeNodeEditor.completer.selector.item'),
});
}
return {
from: preCursor.from,
options,
};
},
},
});

View File

@@ -0,0 +1,13 @@
import { snippets } from '@codemirror/lang-javascript';
import { completeFromList, snippetCompletion } from "@codemirror/autocomplete";
/**
* https://github.com/codemirror/lang-javascript/blob/main/src/snippets.ts
*/
export const jsSnippets = completeFromList([
...snippets.filter((snippet) => snippet.label !== 'class'),
snippetCompletion('console.log(${arg})', { label: 'console.log()' }),
snippetCompletion('DateTime', { label: 'DateTime' }),
snippetCompletion('Interval', { label: 'Interval' }),
snippetCompletion('Duration', { label: 'Duration' }),
]);

View File

@@ -0,0 +1,302 @@
import Vue from 'vue';
import { isAllowedInDotNotation, 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';
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* - Complete `x.first().json.` to `.field`.
* - Complete `x.last().json.` to `.field`.
* - Complete `x.all()[index].json.` to `.field`.
* - Complete `x.item.json.` to `.field`.
*
* - Complete `x.first().json[` to `['field']`.
* - Complete `x.last().json[` to `['field']`.
* - Complete `x.all()[index].json[` to `['field']`.
* - Complete `x.item.json[` to `['field']`.
*/
matcherJsonFieldCompletions(
context: CompletionContext,
matcher: string,
variablesToValues: Record<string, string>,
): CompletionResult | null {
const pattern = new RegExp(`(${escape(matcher)})\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const inputNodeName = this.getInputNodeName();
if (!inputNodeName) return null;
const [varName] = preCursor.text.split('.');
const originalValue = variablesToValues[varName];
if (!originalValue) return null;
for (const accessor of ['first', 'last', 'item']) {
/**
* const x = $input.first(); // accessor in original value
* x.json
*
* const x = $input;
* x.first().json // accessor in preCursor.text
*/
if (originalValue.includes(accessor) || preCursor.text.includes(accessor)) {
const jsonOutput = this.getJsonOutput(inputNodeName, { accessor });
if (!jsonOutput) return null;
return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher);
}
}
if (originalValue.includes('all')) {
const match = originalValue.match(/\$(input|\(.*\))\.all\(\)\[(?<index>.+)\]$/);
if (!match?.groups?.index) return null;
const { index } = match.groups;
const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) });
if (!jsonOutput) return null;
return this.toJsonFieldCompletions(preCursor, jsonOutput, matcher);
}
return null;
},
/**
* - Complete `$input.first().json.` to `.field`.
* - Complete `$input.last().json.` to `.field`.
* - Complete `$input.all()[index].json.` to `.field`.
* - Complete `$input.item.json.` to `.field`.
*
* - Complete `$input.first().json[` to `['field']`.
* - Complete `$input.last().json[` to `['field']`.
* - Complete `$input.all()[index].json[` to `['field']`.
* - Complete `$input.item.json[` to `['field']`.
*/
inputJsonFieldCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$input\.first\(\)\.json(\[|\.).*/,
last: /\$input\.last\(\)\.json(\[|\.).*/,
item: /\$input\.item\.json(\[|\.).*/,
all: /\$input\.all\(\)\[(?<index>\w+)\]\.json(\[|\.).*/,
};
for (const [name, regex] of Object.entries(patterns)) {
const preCursor = context.matchBefore(regex);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
const inputNodeName = this.getInputNodeName();
if (!inputNodeName) continue;
if (name === 'first' || name === 'last') {
const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: name });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.${name}().json`);
}
if (name === 'item') {
const jsonOutput = this.getJsonOutput(inputNodeName, { accessor: 'item' });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(preCursor, jsonOutput, '$input.item.json');
}
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
const jsonOutput = this.getJsonOutput(inputNodeName, { index: Number(index) });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(preCursor, jsonOutput, `$input.all()[${index}].json`);
}
}
return null;
},
/**
* Complete `$('nodeName').first().json.` to `.field`.
* Complete `$('nodeName').last().json.` to `.field`.
* Complete `$('nodeName').all()[index].json.` to `.field`.
* Complete `$('nodeName').item.json.` to `.field`.
*
* Complete `$('nodeName').first().json[` to `['field']`.
* Complete `$('nodeName').last().json[` to `['field']`.
* Complete `$('nodeName').all()[index].json[` to `['field']`.
* Complete `$('nodeName').item.json[` to `['field']`.
*/
selectorJsonFieldCompletions(context: CompletionContext): CompletionResult | null {
const patterns = {
first: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.first\(\)\.json(\[|\.).*/,
last: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.last\(\)\.json(\[|\.).*/,
item: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.item\.json(\[|\.).*/,
all: /\$\((?<quotedNodeName>['"][\w\s]+['"])\)\.all\(\)\[(?<index>\w+)\]\.json(\[|\.).*/,
};
for (const [name, regex] of Object.entries(patterns)) {
const preCursor = context.matchBefore(regex);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) continue;
const match = preCursor.text.match(regex);
if (!match?.groups?.quotedNodeName) continue;
const { quotedNodeName } = match.groups;
const selector = `$(${match.groups.quotedNodeName})`;
if (name === 'first' || name === 'last') {
const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: name });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.${name}().json`);
}
if (name === 'item') {
const jsonOutput = this.getJsonOutput(quotedNodeName, { accessor: 'item' });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(preCursor, jsonOutput, `${selector}.item.json`);
}
if (name === 'all') {
const match = preCursor.text.match(regex);
if (!match?.groups?.index) continue;
const { index } = match.groups;
const jsonOutput = this.getJsonOutput(quotedNodeName, { index: Number(index) });
if (!jsonOutput) continue;
return this.toJsonFieldCompletions(
preCursor,
jsonOutput,
`${selector}.all()[${index}].json`,
);
}
}
return null;
},
getInputNodeName() {
try {
const activeNode = this.$store.getters.activeNode;
const workflow = this.getCurrentWorkflow();
const input = workflow.connectionsByDestinationNode[activeNode.name];
return input.main[0][0].node;
} catch (_) {
return null;
}
},
/**
* .json -> .json['field']
* .json -> .json.field
*/
toJsonFieldCompletions(
preCursor: NonNullable<ReturnType<CompletionContext['matchBefore']>>,
jsonOutput: IDataObject,
matcher: string, // e.g. `$input.first().json` or `x` (user-defined variable)
) {
if (preCursor.text.endsWith('.json[') || preCursor.text.endsWith(`${matcher}[`)) {
const options: Completion[] = Object.keys(jsonOutput)
.map((field) => `${matcher}['${field}']`)
.map((label) => ({
label,
info: this.$locale.baseText('codeNodeEditor.completer.json'),
}));
return {
from: preCursor.from,
options,
};
}
if (preCursor.text.endsWith('.json.') || preCursor.text.endsWith(`${matcher}.`)) {
const options: Completion[] = Object.keys(jsonOutput)
.filter(isAllowedInDotNotation)
.map((field) => `${matcher}.${field}`)
.map(toVariableOption);
return {
from: preCursor.from,
options,
};
}
return null;
},
/**
* Get the `json` output of a node from `runData` or `pinData`.
*
* `accessor` is the method or property used to find the item index.
* `index` is only passed for `all()`.
*/
getJsonOutput(quotedNodeName: string, options?: { accessor?: string; index?: number }) {
const nodeName = quotedNodeName.replace(/['"]/g, '');
const pinData: IPinData | undefined = this.$store.getters.pinData;
const nodePinData = pinData && pinData[nodeName];
if (nodePinData) {
try {
let itemIndex = options?.index ?? 0;
if (options?.accessor === 'last') {
itemIndex = nodePinData.length - 1;
}
return nodePinData[itemIndex].json;
} catch (_) {}
}
const runData: IRunData | null = this.$store.getters.getWorkflowRunData;
const nodeRunData = runData && runData[nodeName];
if (!nodeRunData) return null;
try {
let itemIndex = options?.index ?? 0;
if (options?.accessor === 'last') {
const inputItems = nodeRunData[0].data!.main[0]!;
itemIndex = inputItems.length - 1;
}
return nodeRunData[0].data!.main[0]![itemIndex].json;
} catch (_) {
return null;
}
},
},
});

View File

@@ -0,0 +1,284 @@
import Vue from 'vue';
import { escape } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$today.` with luxon `DateTime` instance methods.
*/
todayCompletions(context: CompletionContext, matcher = '$today'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `$now.` with luxon `DateTime` instance methods.
*/
nowCompletions(context: CompletionContext, matcher = '$now'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonInstanceGetters().map(([getter, description]) => {
return {
label: `${matcher}.${getter}`,
type: 'function',
info: description,
};
});
options.push(...this.luxonInstanceMethods().map(([method, description]) => {
return {
label: `${matcher}.${method}()`,
type: 'function',
info: description,
};
}));
return {
from: preCursor.from,
options,
};
},
/**
* Complete `DateTime` with luxon `DateTime` static methods.
*/
dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = this.luxonDateTimeStaticMethods().map(
([method, description]) => {
return {
label: `DateTime.${method}()`,
type: 'function',
info: description,
};
},
);
return {
from: preCursor.from,
options,
};
},
luxonDateTimeStaticMethods() {
return Object.entries({
now: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
local: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
utc: this.$locale.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
fromJSDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate',
),
fromMillis: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis',
),
fromSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds',
),
fromObject: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject',
),
fromISO: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO',
),
fromRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822',
),
fromHTTP: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP',
),
fromFormat: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat',
),
fromSQL: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL',
),
invalid: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid',
),
isDateTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime',
),
});
},
luxonInstanceGetters() {
return Object.entries({
isValid: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'),
invalidReason: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidReason',
),
invalidExplanation: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation',
),
locale: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'),
numberingSystem: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem',
),
outputCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.outputCalendar',
),
zone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'),
zoneName: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'),
year: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
quarter: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'),
month: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
day: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
hour: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
minute: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
second: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
millisecond: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.millisecond',
),
weekYear: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'),
weekNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekNumber',
),
weekday: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'),
ordinal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'),
monthShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthShort',
),
monthLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.monthLong',
),
weekdayShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayShort',
),
weekdayLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weekdayLong',
),
offset: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'),
offsetNumber: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNumber',
),
offsetNameShort: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort',
),
offsetNameLong: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong',
),
isOffsetFixed: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed',
),
isInDST: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'),
isInLeapYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear',
),
daysInMonth: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInMonth',
),
daysInYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.daysInYear',
),
weeksInWeekYear: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear',
),
});
},
luxonInstanceMethods() {
return Object.entries({
toUTC: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'),
toLocal: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'),
setZone: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'),
setLocale: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.setLocale',
),
set: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'),
plus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'),
minus: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'),
startOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'),
endOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'),
toFormat: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'),
toLocaleString: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleString',
),
toLocaleParts: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts',
),
toISO: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'),
toISODate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISODate',
),
toISOWeekDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate',
),
toISOTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toISOTime',
),
toRFC2822: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRFC2822',
),
toHTTP: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'),
toSQLDate: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLDate',
),
toSQLTime: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSQLTime',
),
toSQL: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'),
toString: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'),
valueOf: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'),
toMillis: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'),
toSeconds: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toSeconds',
),
toUnixInteger: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger',
),
toJSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'),
toBSON: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'),
toObject: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'),
toJsDate: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'),
diff: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'),
diffNow: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'),
until: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'),
hasSame: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'),
equals: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'),
toRelative: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelative',
),
toRelativeCalendar: this.$locale.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar',
),
min: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'),
max: this.$locale.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'),
});
},
},
});

View File

@@ -0,0 +1,46 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const DEFAULT_MATCHER = '$prevNode';
const escape = (str: string) => str.replace('$', '\\$');
export const prevNodeCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$prevNode.` to `.name .outputIndex .runIndex`.
*/
prevNodeCompletions(
context: CompletionContext,
matcher = DEFAULT_MATCHER,
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.name`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.name'),
},
{
label: `${matcher}.outputIndex`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),
},
{
label: `${matcher}.runIndex`,
info: this.$locale.baseText('codeNodeEditor.completer.$prevNode.runIndex'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View File

@@ -0,0 +1,41 @@
import Vue from 'vue';
import { AUTOCOMPLETABLE_BUILT_IN_MODULES } from '../constants';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
export const requireCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `req` to `require('moduleName')` based on modules available in context.
*/
requireCompletions(context: CompletionContext): CompletionResult | null {
const preCursor = context.matchBefore(/req.*/);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [];
const allowedModules = this.$store.getters['settings/allowedModules'];
const toOption = (moduleName: string) => ({
label: `require('${moduleName}');`,
type: 'variable',
});
if (allowedModules?.builtIn?.includes('*')) {
options.push(...AUTOCOMPLETABLE_BUILT_IN_MODULES.map(toOption));
} else if (allowedModules?.builtIn?.length > 0) {
options.push(...allowedModules.builtIn.map(toOption));
}
if (allowedModules?.external?.length > 0) {
options.push(...allowedModules.external.map(toOption));
}
return {
from: preCursor.from,
options,
};
},
},
});

View File

@@ -0,0 +1,44 @@
import Vue from 'vue';
import { addVarType } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { CodeNodeEditorMixin } from '../types';
const escape = (str: string) => str.replace('$', '\\$');
export const workflowCompletions = (Vue as CodeNodeEditorMixin).extend({
methods: {
/**
* Complete `$workflow.` to `.id .name .active`.
*/
workflowCompletions(
context: CompletionContext,
matcher = '$workflow',
): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = [
{
label: `${matcher}.id`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.id'),
},
{
label: `${matcher}.name`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.name'),
},
{
label: `${matcher}.active`,
info: this.$locale.baseText('codeNodeEditor.completer.$workflow.active'),
},
];
return {
from: preCursor.from,
options: options.map(addVarType),
};
},
},
});

View File

@@ -0,0 +1,53 @@
import { STICKY_NODE_TYPE } from '@/constants';
import type { Diagnostic } from '@codemirror/lint';
export const NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION = [STICKY_NODE_TYPE];
export const AUTOCOMPLETABLE_BUILT_IN_MODULES = [
'console',
'constants',
'crypto',
'dns',
'dns/promises',
'fs',
'fs/promises',
'http',
'http2',
'https',
'inspector',
'module',
'os',
'path',
'process',
'readline',
'url',
'util',
'zlib',
];
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
/**
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
*/
export const OFFSET_FOR_SCRIPT_WRAPPER = 'module.exports = async function() {'.length;
export const ALL_ITEMS_PLACEHOLDER = `
// Loop over input items and add a new field
// called 'myNewField' to the JSON of each one
for (const item of $input.all()) {
item.json.myNewField = 1;
}
return $input.all();
`.trim();
export const EACH_ITEM_PLACEHOLDER = `
// Add a new field called 'myNewField' to the
// JSON of the item
$input.item.json.myNewField = 1;
return $input.item;
`.trim();

View File

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,577 @@
import Vue from 'vue';
import { Diagnostic, linter as createLinter } from '@codemirror/lint';
import * as esprima from 'esprima';
import {
DEFAULT_LINTER_DELAY_IN_MS,
DEFAULT_LINTER_SEVERITY,
OFFSET_FOR_SCRIPT_WRAPPER,
} from './constants';
import { walk } from './utils';
import type { EditorView } from '@codemirror/view';
import type { Node } from 'estree';
import type { CodeNodeEditorMixin, RangeNode } from './types';
export const linterExtension = (Vue as CodeNodeEditorMixin).extend({
methods: {
linterExtension() {
return createLinter(this.lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
},
lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString();
const script = `module.exports = async function() {${doc}\n}()`;
let ast: esprima.Program | null = null;
try {
ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) {
let line;
try {
line = editorView.state.doc.line(syntaxError.lineNumber);
return [
{
from: line.from,
to: line.to,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
},
];
} catch (error) {
/**
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
*/
return [];
}
}
const lintings: Diagnostic[] = [];
/**
* Lint for incorrect `.item()` instead of `.item` in `runOnceForEachItem` mode
*
* $input.item() -> $input.item
*/
if (this.mode === 'runOnceForEachItem') {
const isItemCall = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'item';
walk(ast, isItemCall).forEach((node) => {
const [start, end] = this.getRange(node);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemCall'),
actions: [
{
name: 'Fix',
apply(view, _, to) {
view.dispatch({ changes: { from: end - '()'.length, to } });
},
},
],
});
});
}
/**
* Lint for `$json`, `$binary` and `$itemIndex` unavailable in `runOnceForAllItems` mode
*
* $json -> <removed>
*/
if (this.mode === 'runOnceForAllItems') {
const isUnavailableVarInAllItems = (node: Node) =>
node.type === 'Identifier' && ['$json', '$binary', '$itemIndex'].includes(node.name);
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = this.getRange(node);
const varName = this.getText(node);
if (!varName) return;
const message = [
`\`${varName}\``,
this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableVar'),
].join(' ');
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message,
actions: [
{
name: 'Remove',
apply(view, from, to) {
view.dispatch({ changes: { from, to } });
},
},
],
});
});
}
/**
* Lint for `.item` unavailable in `runOnceForAllItems` mode
*
* $input.all().item -> <removed>
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { property: RangeNode };
const isUnavailablePropertyinAllItems = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.property.type === 'Identifier' &&
node.property.name === 'item';
walk<TargetNode>(ast, isUnavailablePropertyinAllItems).forEach((node) => {
const [start, end] = this.getRange(node.property);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
actions: [
{
name: 'Remove',
apply(view) {
view.dispatch({ changes: { from: start - '.'.length, to: end } });
},
},
],
});
});
}
/**
* Lint for `item` (legacy var from Function Item node) being accessed
* in `runOnceForEachItem` mode, unless user-defined `item`.
*
* item. -> $input.item.json.
*/
if (this.mode === 'runOnceForEachItem' && !/(let|const|var) item =/.test(script)) {
type TargetNode = RangeNode & { object: RangeNode & { name: string } };
const isItemAccess = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.object.type === 'Identifier' &&
node.object.name === 'item';
walk<TargetNode>(ast, isItemAccess).forEach((node) => {
const [start, end] = this.getRange(node.object);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.legacyItemAccess'),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('$input.item.json')) {
return;
}
view.dispatch({ changes: { from: start, to: end } });
view.dispatch({ changes: { from, insert: '$input.item.json' } });
},
},
],
});
});
}
/**
* Lint for `.first()`, `.last()`, `.all()` and `.itemMatching()`
* unavailable in `runOnceForEachItem` mode
*
* $input.first()
* $input.last()
* $input.all()
* $input.itemMatching()
*/
if (this.mode === 'runOnceForEachItem') {
type TargetNode = RangeNode & { property: RangeNode & { name: string } };
const isUnavailableMethodinEachItem = (node: Node) =>
node.type === 'MemberExpression' &&
node.computed === false &&
node.property.type === 'Identifier' &&
['first', 'last', 'all', 'itemMatching'].includes(node.property.name);
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = this.getRange(node.property);
const message = [
`\`.${node.property.name}()\``,
this.$locale.baseText('codeNodeEditor.linter.eachItem.unavailableMethod'),
].join(' ');
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message,
});
});
}
/**
* Lint for `.itemMatching()` called with no argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'itemMatching' &&
node.arguments.length === 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.itemMatchingNoArg'),
});
});
}
/**
* Lint for `.all()` called with argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & { callee: RangeNode & { property: RangeNode } };
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'all' &&
node.arguments.length !== 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.allItems.allCalledWithArg'),
});
});
}
/**
* Lint for `.first()` or `.last()` called with argument in `runOnceForAllItems` mode
*
* $input.itemMatching()
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & {
callee: RangeNode & { property: { name: string } & RangeNode };
};
const isItemMatchingCallWithoutArg = (node: Node) =>
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
['first', 'last'].includes(node.callee.property.name) &&
node.arguments.length !== 0;
walk<TargetNode>(ast, isItemMatchingCallWithoutArg).forEach((node) => {
const [start, end] = this.getRange(node.callee.property);
const message = [
`\`.${node.callee.property.name}()\``,
this.$locale.baseText('codeNodeEditor.linter.allItems.firstOrLastCalledWithArg'),
].join(' ');
lintings.push({
from: start,
to: end + '()'.length,
severity: DEFAULT_LINTER_SEVERITY,
message,
});
});
}
/**
* Lint for empty (i.e. no value) return
*
* return -> <no autofix>
*/
const isEmptyReturn = (node: Node) =>
node.type === 'ReturnStatement' && node.argument === null;
const emptyReturnMessage =
this.mode === 'runOnceForAllItems'
? this.$locale.baseText('codeNodeEditor.linter.allItems.emptyReturn')
: this.$locale.baseText('codeNodeEditor.linter.eachItem.emptyReturn');
walk<RangeNode>(ast, isEmptyReturn).forEach((node) => {
const [start, end] = node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: emptyReturnMessage,
});
});
/**
* Lint for array return in `runOnceForEachItem` mode
*
* return [] -> <no autofix>
*/
if (this.mode === 'runOnceForEachItem') {
const isArrayReturn = (node: Node) =>
node.type === 'ReturnStatement' &&
node.argument !== null &&
node.argument !== undefined &&
node.argument.type === 'ArrayExpression';
walk<RangeNode>(ast, isArrayReturn).forEach((node) => {
const [start, end] = this.getRange(node);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText('codeNodeEditor.linter.eachItem.returnArray'),
});
});
}
/**
* Lint for direct access to item property (i.e. not using `json`)
* in `runOnceForAllItems` mode
*
* item.myField = 123 -> item.json.myField = 123;
* const a = item.myField -> const a = item.json.myField;
*/
if (this.mode === 'runOnceForAllItems') {
type TargetNode = RangeNode & {
left: { declarations: Array<{ id: { type: string; name: string } }> };
};
const isForOfStatement = (node: Node) =>
node.type === 'ForOfStatement' &&
node.left.type === 'VariableDeclaration' &&
node.left.declarations.length === 1 &&
node.left.declarations[0].type === 'VariableDeclarator' &&
node.left.declarations[0].id.type === 'Identifier';
const found = walk<TargetNode>(ast, isForOfStatement);
if (found.length === 1) {
const itemAlias = found[0].left.declarations[0].id.name;
/**
* for (const item of $input.all()) {
* const item = {}; // shadow item
* }
*/
const isShadowItemVar = (node: Node) =>
node.type === 'VariableDeclarator' &&
node.id.type === 'Identifier' &&
node.id.name === 'item' &&
node.init !== null;
const shadowFound = walk(ast, isShadowItemVar);
let shadowStart: undefined | number;
if (shadowFound.length > 0) {
const [shadow] = shadowFound;
const [_shadowStart] = this.getRange(shadow);
shadowStart = _shadowStart;
}
const isDirectAccessToItem = (node: Node) =>
node.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
node.object.name === itemAlias &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name);
walk(ast, isDirectAccessToItem).forEach((node) => {
const [start, end] = this.getRange(node);
if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = this.getText(node);
if (!varName) return;
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: from + itemAlias.length, insert: '.json' } });
},
},
],
});
});
}
}
/**
* Lint for direct access to item property (i.e. not using `json`)
* in `runOnceForEachItem` mode
*
* $input.item.myField = 123 -> $input.item.json.myField = 123;
* const a = $input.item.myField -> const a = $input.item.json.myField;
*/
if (this.mode === 'runOnceForEachItem') {
type TargetNode = RangeNode & { object: { property: RangeNode } };
const isDirectAccessToItemSubproperty = (node: Node) =>
node.type === 'MemberExpression' &&
node.object.type === 'MemberExpression' &&
node.object.property.type === 'Identifier' &&
node.object.property.name === 'item' &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = this.getText(node);
if (!varName) return;
const [start, end] = this.getRange(node);
const [_, fixEnd] = this.getRange(node.object.property);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.itemProperty',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
},
},
],
});
});
}
/**
* Lint for direct access to `first()` or `last()` output (i.e. not using `json`)
*
* $input.first().myField -> $input.first().json.myField
*/
type TargetNode = RangeNode & { object: RangeNode };
const isDirectAccessToFirstOrLastCall = (node: Node) =>
node.type === 'MemberExpression' &&
node.property.type === 'Identifier' &&
!['json', 'binary'].includes(node.property.name) &&
node.object.type === 'CallExpression' &&
node.object.arguments.length === 0 &&
node.object.callee.type === 'MemberExpression' &&
node.object.callee.property.type === 'Identifier' &&
['first', 'last'].includes(node.object.callee.property.name);
walk<TargetNode>(ast, isDirectAccessToFirstOrLastCall).forEach((node) => {
const [start, end] = this.getRange(node);
const [_, fixEnd] = this.getRange(node.object);
lintings.push({
from: start,
to: end,
severity: DEFAULT_LINTER_SEVERITY,
message: this.$locale.baseText(
'codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall',
),
actions: [
{
name: 'Fix',
apply(view, from, to) {
// prevent second insertion of unknown origin
if (view.state.doc.toString().slice(from, to).includes('.json')) return;
view.dispatch({ changes: { from: fixEnd, insert: '.json' } });
},
},
],
});
});
return lintings;
},
// ----------------------------------
// helpers
// ----------------------------------
getText(node: RangeNode) {
if (!this.editor) return null;
const [start, end] = this.getRange(node);
return this.editor.state.doc.toString().slice(start, end);
},
getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
},
},
});

View File

@@ -0,0 +1,122 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
/**
* Based on Tomorrow theme by Chris Kempson
* https://github.com/vadimdemedes/thememirror/blob/main/source/themes/tomorrow.ts
*/
const BASE_STYLING = {
fontSize: '0.8em',
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
background: '#FFFFFF',
foreground: '#4D4D4C',
caret: '#AEAFAD',
selection: '#D6D6D6',
gutterBackground: '#FFFFFF',
gutterForeground: '#4D4D4C80',
lineHighlight: '#EFEFEF',
maxHeight: '400px',
tooltip: {
maxWidth: '300px',
lineHeight: '1.3em',
},
diagnosticButton: {
backgroundColor: 'inherit',
lineHeight: '1em',
textDecoration: 'underline',
marginLeft: '0.2em',
cursor: 'pointer',
},
};
const HIGHLIGHT_STYLING = [
{
tag: tags.comment,
color: '#8E908C',
},
{
tag: [tags.variableName, tags.self, tags.propertyName, tags.attributeName, tags.regexp],
color: '#C82829',
},
{
tag: [tags.number, tags.bool, tags.null],
color: '#F5871F',
},
{
tag: [tags.className, tags.typeName, tags.definition(tags.typeName)],
color: '#C99E00',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: '#718C00',
},
{
tag: tags.operator,
color: '#3E999F',
},
{
tag: [tags.definition(tags.propertyName), tags.function(tags.variableName)],
color: '#4271AE',
},
{
tag: tags.keyword,
color: '#8959A8',
},
{
tag: tags.derefOperator,
color: '#4D4D4C',
},
];
const cssStyleDeclaration = getComputedStyle(document.documentElement);
export const CODE_NODE_EDITOR_THEME = [
EditorView.theme({
'&': {
backgroundColor: BASE_STYLING.background,
color: BASE_STYLING.foreground,
'font-size': BASE_STYLING.fontSize,
border: cssStyleDeclaration.getPropertyValue('--border-base'),
borderRadius: cssStyleDeclaration.getPropertyValue('--border-radius-base'),
},
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: BASE_STYLING.caret,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: BASE_STYLING.caret,
},
'.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth,
lineHeight: BASE_STYLING.tooltip.lineHeight,
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: BASE_STYLING.selection,
},
'.cm-activeLine': {
backgroundColor: BASE_STYLING.lineHighlight,
},
'.cm-gutters': {
backgroundColor: BASE_STYLING.gutterBackground,
color: BASE_STYLING.gutterForeground,
},
'.cm-activeLineGutter': {
backgroundColor: BASE_STYLING.lineHighlight,
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: BASE_STYLING.maxHeight,
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: cssStyleDeclaration.getPropertyValue('--color-primary'),
lineHeight: BASE_STYLING.diagnosticButton.lineHeight,
textDecoration: BASE_STYLING.diagnosticButton.textDecoration,
marginLeft: BASE_STYLING.diagnosticButton.marginLeft,
cursor: BASE_STYLING.diagnosticButton.cursor,
},
}),
syntaxHighlighting(HighlightStyle.define(HIGHLIGHT_STYLING)),
];

View File

@@ -0,0 +1,15 @@
import type { EditorView } from '@codemirror/view';
import type { I18nClass } from '@/plugins/i18n';
import type { Workflow } from 'n8n-workflow';
import type { Node } from 'estree';
export type CodeNodeEditorMixin = Vue.VueConstructor<
Vue & {
$locale: I18nClass;
editor: EditorView | null;
mode: 'runOnceForAllItems' | 'runOnceForEachItem';
getCurrentWorkflow(): Workflow;
}
>;
export type RangeNode = Node & { range: [number, number] };

View File

@@ -0,0 +1,46 @@
import type { Completion } from '@codemirror/autocomplete';
import type { Node } from 'estree';
import type { RangeNode } from './types';
export function walk<T extends RangeNode>(
node: Node,
test: (node: Node) => boolean,
found: Node[] = [],
) {
if (test(node)) found.push(node);
for (const key in node) {
if (!(key in node)) continue;
// @ts-ignore
const child = node[key];
if (child === null || typeof child !== 'object') continue;
if (Array.isArray(child)) {
child.forEach((node) => walk(node, test, found));
} else {
walk(child, test, found);
}
}
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('$', '\\$')
.replace('(', '\\(')
.replace(')', '\\)')
.replace('[', '\\[')
.replace(']', '\\]');
export const toVariableOption = (label: string) => ({ label, type: 'variable' });
export const addVarType = (option: Completion) => ({ ...option, type: 'variable' });