UNPKG

@bpmn-io/feel-lint

Version:
408 lines (340 loc) 10.1 kB
import { parser, trackVariables } from '@bpmn-io/lezer-feel'; import { FeelAnalyzer } from '@bpmn-io/feel-analyzer'; import { isCompatible } from '@bpmn-io/semver-compat'; import { syntaxTree } from '@codemirror/language'; /** * @typedef {import('@lezer/common').Tree} Tree * @typedef {import('@codemirror/lint').Diagnostic} LintMessage */ /** * Create an array of syntax errors in the given tree. * * @param {Tree} syntaxTree * @returns {LintMessage[]} array of syntax errors */ function lintSyntax(syntaxTree) { const lintMessages = []; syntaxTree.iterate({ enter: ref => { const node = ref.node; if (!node.type.isError) { return; } const parent = node.parent; const next = getNextNode(node); const message = { from: node.from, to: node.to, severity: 'error', type: 'Syntax Error' }; if (node.from !== node.to) { message.message = `Unrecognized token in <${parent.name}>`; } else if (next) { message.message = `Unrecognized token <${next.name}> in <${parent.name}>`; message.to = next.to; } else { const before = parent.enterUnfinishedNodesBefore(node.to); message.message = `Incomplete <${ (before || parent).name }>`; } lintMessages.push(message); } }); return lintMessages; } function getNextNode(node) { if (!node) { return null; } return node.nextSibling || getNextNode(node.parent); } /** * @typedef {object} Context * @property {function} report * @property {(from: number, to: number) => string} readContent * @property {(from: number, to: number, content: string) => void} updateContent */ const RULE_NAME$1 = 'first-item'; var firstItem = { create(/** @type {Context} */ context) { return { enter(node) { if (node.name !== 'FilterExpression') { return; } const content = context.readContent(node.from, node.to); if (zeroIndexPattern().test(content)) { const { from, to } = node; context.report({ from, to, message: 'First item is accessed via [1]', severity: 'warning', type: RULE_NAME$1, actions: [ { name: 'fix', apply(_, start = from, end = to) { context.updateContent(start, end, content.replace(zeroIndexPattern(), '[1]')); } } ] }); } } }; } }; function zeroIndexPattern() { return /\[\s*0\s*\]$/; } /** * @typedef {import('@lezer/common').Tree} Tree * @typedef {import('@codemirror/lint').Diagnostic} LintMessage * @typedef {import('./index').LintAllContext} LintAllContext */ const RULES = [ firstItem ]; /** * Create an array of messages reported from rules in the given tree. * * @param {LintAllContext} context * @returns {LintMessage[]} array of syntax errors */ function lintRules(context) { const { readContent, syntaxTree, updateContent } = context; const lintMessages = []; const ruleContext = { readContent, report: message => { lintMessages.push(message); }, updateContent }; const rules = RULES.map(rule => rule.create(ruleContext)); syntaxTree.iterate({ enter: ref => { for (const rule of rules) { rule.enter && rule.enter(ref); } }, leave: ref => { for (const rule of rules) { rule.leave && rule.leave(ref); } } }); return lintMessages; } /** * @typedef {import('@lezer/common').Tree} Tree * @typedef {import('../lib/text/util.js').Variable} Variable * @typedef {import('../lib/shared/index.js').LintMessage} LintMessage * * @typedef {object} CompatibilityContext * @property {Tree} syntaxTree the already-parsed syntax tree * @property {string} expression the source the tree was parsed from * @property {Record<string, string>} [engines] provided engine versions, e.g. `{ camunda: '8.6' }` * @property {Variable[]} [builtins] built-ins, carrying `engines` requirements */ const RULE_NAME = 'compatibility'; /** * Reports calls to built-in functions that are not available in the provided * engine version(s). * * Reuses the already-parsed `syntaxTree` (via feel-analyzer) instead of * re-parsing. No-op unless `engines` is provided and built-ins carry `engines` * metadata. * * @param {CompatibilityContext} context * * @returns {LintMessage[]} */ function lintCompatibility(context = {}) { const { syntaxTree, expression, engines, builtins = [] } = context; if (!engines || !Object.keys(engines).length || !builtins.length) { return []; } const unavailable = getUnavailableBuiltins(builtins, engines); if (!unavailable.size) { return []; } const analyzer = new FeelAnalyzer({ builtins }); const { valid, functions = [] } = analyzer.analyzeTree(syntaxTree, expression); // syntax errors are reported separately; don't double-report on broken input if (!valid) { return []; } return functions.reduce((messages, fn) => { if (fn.type !== 'builtin') { return messages; } const builtin = unavailable.get(fn.name); if (!builtin) { return messages; } messages.push({ from: fn.from, to: fn.to, severity: 'warning', type: RULE_NAME, message: `Function '${ fn.name }' requires ${ formatEngines(builtin.engines) }` }); return messages; }, []); } // helpers ////////// function getUnavailableBuiltins(builtins, engines) { const unavailable = new Map(); for (const builtin of builtins) { if (builtin.engines && !isCompatible(builtin.engines, engines)) { unavailable.set(builtin.name, builtin); } } return unavailable; } function formatEngines(engines) { return Object.entries(engines) .map(([ name, range ]) => `${ capitalize(name) } ${ range }`) .join(', '); } function capitalize(string) { return string.charAt(0).toUpperCase() + string.slice(1); } /** * @typedef {import('@lezer/common').Tree} Tree * @typedef {import('@codemirror/lint').Diagnostic} LintMessage * @typedef {import('../text/util.js').Variable} Variable */ /** * @typedef {object} LintAllContext * @property {Tree} syntaxTree * @property {(from: number, to: number) => string} readContent * @property {(from: number, to: number, content: string) => void} updateContent * @property {string} [expression] source the tree was parsed from (for compatibility linting) * @property {Record<string, string>} [engines] provided engine versions, e.g. `{ camunda: '8.6' }` * @property {Variable[]} [builtins] */ /** * Generates lint messages for the given context. * * @param {LintAllContext} context * @returns {LintMessage[]} array of all lint messages */ function lintAll(context) { const lintMessages = [ ...lintSyntax(context.syntaxTree), ...lintRules(context), ...lintCompatibility(context) ]; return lintMessages; } /** * @typedef {object} Variable * @property {string} name name or key of the variable * @property {string} [info] longer description of the variable content * @property {string} [detail] short information about the variable, e.g. type * @property {boolean} [isList] whether the variable is a list * @property {Array<Variable>} [schema] array of child variables if the variable is a context or list * @property {Array<{name: string, type: string}>} [params] function parameters * @property {Record<string, string>} [engines] engine version requirements, e.g. `{ camunda: '>=8.9' }` */ /** * @param { Variable[] } variables * * @return {Record<string, any>} */ function createContext(variables) { return variables.slice().reverse().reduce((context, variable) => { context[variable.name] = () => {}; return context; }, {}); } /** * Create an array of syntax errors for the given expression. * * @param {String} expression * @param { { * dialect?: 'expression' | 'unaryTests', * parserDialect?: string, * builtins?: import("./util.js").Variable[], * variables?: import("./util.js").Variable[], * engines?: Record<string, string>, * } } [lintOptions] * * @returns {import("../shared").LintMessage[]} array of lint messages */ function lintExpression(expression, { dialect = 'expression', parserDialect, builtins = [], variables = [], engines, } = {}) { const context = createContext([ ...builtins, ...variables ]); const syntaxTree = parser.configure({ top: dialect === 'unaryTests' ? 'UnaryTests' : 'Expression', dialect: parserDialect, contextTracker: trackVariables(context) }).parse(expression); const lintMessages = lintAll({ syntaxTree, expression, builtins, engines, readContent: (from, to) => expression.slice(from, to), updateContent: (from, to, content) => { // not implemented } }); return lintMessages; } /** * CodeMirror extension that provides linting for FEEL expressions. * * @param { { * builtins?: import('../text/util.js').Variable[], * engines?: Record<string, string>, * } } [options] enables version-compatibility linting when `engines` is set * * @returns {import('@codemirror/lint').LintSource} CodeMirror linting source */ const cmFeelLinter = ({ builtins = [], engines } = {}) => editorView => { // don't lint if the Editor is empty if (editorView.state.doc.length === 0) { return []; } const tree = syntaxTree(editorView.state); const messages = lintAll({ syntaxTree: tree, expression: editorView.state.doc.toString(), builtins, engines, readContent: (from, to) => editorView.state.sliceDoc(from, to), updateContent: (from, to, content) => editorView.dispatch({ changes: { from, to, insert: content } }) }); return messages.map(message => ({ ...message, source: message.type })); }; export { cmFeelLinter, lintExpression }; //# sourceMappingURL=index.js.map