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:
577
packages/editor-ui/src/components/CodeNodeEditor/linter.ts
Normal file
577
packages/editor-ui/src/components/CodeNodeEditor/linter.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user