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