@bpmn-io/feel-editor
Version:
Editor for FEEL expressions.
728 lines (618 loc) • 16.8 kB
JavaScript
;
var autocomplete = require('@codemirror/autocomplete');
var commands = require('@codemirror/commands');
var language$1 = require('@codemirror/language');
var lint = require('@codemirror/lint');
var state = require('@codemirror/state');
var view = require('@codemirror/view');
var feelLint = require('@bpmn-io/feel-lint');
var highlight = require('@lezer/highlight');
var langFeel = require('lang-feel');
var minDom = require('min-dom');
var feelBuiltins = require('@camunda/feel-builtins');
var linter = [ lint.linter(feelLint.cmFeelLinter()) ];
const baseTheme = view.EditorView.theme({
'& .cm-content': {
padding: '0px',
},
'& .cm-line': {
padding: '0px',
},
'&.cm-editor.cm-focused': {
outline: 'none',
},
'& .cm-completionInfo': {
whiteSpace: 'pre-wrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
},
'&.cm-editor': {
height: '100%',
},
// Don't wrap whitespace for custom HTML
'& .cm-completionInfo > *': {
whiteSpace: 'normal'
},
'& .cm-completionInfo ul': {
margin: 0,
paddingLeft: '15px'
},
'& .cm-completionInfo pre': {
marginBottom: 0,
whiteSpace: 'pre-wrap'
},
'& .cm-completionInfo p': {
marginTop: 0,
},
'& .cm-completionInfo p:not(:last-of-type)': {
marginBottom: 0,
}
});
const highlightTheme = view.EditorView.baseTheme({
'& .variableName': {
color: '#10f'
},
'& .number': {
color: '#164'
},
'& .string': {
color: '#a11'
},
'& .bool': {
color: '#219'
},
'& .function': {
color: '#aa3731',
fontWeight: 'bold'
},
'& .control': {
color: '#708'
}
});
const syntaxClasses = language$1.syntaxHighlighting(
language$1.HighlightStyle.define([
{ tag: highlight.tags.variableName, class: 'variableName' },
{ tag: highlight.tags.name, class: 'variableName' },
{ tag: highlight.tags.number, class: 'number' },
{ tag: highlight.tags.string, class: 'string' },
{ tag: highlight.tags.bool, class: 'bool' },
{ tag: highlight.tags.function(highlight.tags.variableName), class: 'function' },
{ tag: highlight.tags.function(highlight.tags.special(highlight.tags.variableName)), class: 'function' },
{ tag: highlight.tags.controlKeyword, class: 'control' },
{ tag: highlight.tags.operatorKeyword, class: 'control' }
])
);
var theme = [ baseTheme, highlightTheme, syntaxClasses ];
// helpers ///////////////////////////////
function _isEmpty(node) {
return node && node.from === node.to;
}
/**
* @param {any} node
* @param {number} pos
*
* @return {boolean}
*/
function isEmpty(node, pos) {
// For the special case of empty nodes, we need to check the current node
// as well. The previous node could be part of another token, e.g.
// when typing functions "abs(".
const nextNode = node.nextSibling;
return _isEmpty(node) || (
nextNode && nextNode.from === pos && _isEmpty(nextNode)
);
}
function isVariableName(node) {
return node && node.parent && node.parent.name === 'VariableName';
}
function isPathExpression(node) {
if (!node) {
return false;
}
if (node.name === 'PathExpression') {
return true;
}
return isPathExpression(node.parent);
}
/**
* @typedef { import('../core').Variable } Variable
* @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource
*/
/**
* @param { {
* variables?: Variable[],
* } } options
*
* @return { CompletionSource }
*/
function pathExpressionCompletion({ variables }) {
return (context) => {
const nodeBefore = language$1.syntaxTree(context.state).resolve(context.pos, -1);
if (!isPathExpression(nodeBefore)) {
return;
}
const expression = findPathExpression(nodeBefore);
// if the cursor is directly after the `.`, variable starts at the cursor position
const from = nodeBefore === expression ? context.pos : nodeBefore.from;
const path = getPath(expression, context);
let options = variables;
for (var i = 0; i < path.length - 1; i++) {
var childVar = options.find(val => val.name === path[i].name);
if (!childVar) {
return null;
}
// only suggest if variable type matches
if (
childVar.isList !== 'optional' &&
!!childVar.isList !== path[i].isList
) {
return;
}
options = childVar.entries;
}
if (!options) return;
options = options.map(v => ({
label: v.name,
type: 'variable',
info: v.info,
detail: v.detail
}));
const result = {
from: from,
options: options
};
return result;
};
}
function findPathExpression(node) {
while (node) {
if (node.name === 'PathExpression') {
return node;
}
node = node.parent;
}
}
// parses the path expression into a list of variable names with type information
// e.g. foo[0].bar => [ { name: 'foo', isList: true }, { name: 'bar', isList: false } ]
function getPath(node, context) {
let path = [];
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.name === 'PathExpression') {
path.push(...getPath(child, context));
} else if (child.name === 'FilterExpression') {
path.push(...getFilter(child, context));
}
else {
path.push({
name: getNodeContent(child, context),
isList: false
});
}
}
return path;
}
function getFilter(node, context) {
const list = node.firstChild;
if (list.name === 'PathExpression') {
const path = getPath(list, context);
const last = path[path.length - 1];
last.isList = true;
return path;
}
return [ {
name: getNodeContent(list, context),
isList: true
} ];
}
function getNodeContent(node, context) {
return context.state.sliceDoc(node.from, node.to);
}
/**
* @typedef { import('../core').Variable } Variable
* @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource
*/
/**
* @param { {
* variables?: Variable[],
* builtins?: Variable[]
* } } options
*
* @return { CompletionSource }
*/
function variableCompletion({ variables = [], builtins = [] }) {
const options = getVariableSuggestions(variables, builtins);
if (!options.length) {
return (context) => null;
}
return (context) => {
const {
pos,
state
} = context;
// in most cases, use what is typed before the cursor
const nodeBefore = language$1.syntaxTree(state).resolve(pos, -1);
if (isEmpty(nodeBefore, pos)) {
return context.explicit ? {
from: pos,
options
} : null;
}
// only auto-complete variables
if (!isVariableName(nodeBefore) || isPathExpression(nodeBefore)) {
return null;
}
return {
from: nodeBefore.from,
options
};
};
}
/**
* @param { Variable[] } variables
* @param { Variable[] } builtins
*
* @returns {import('@codemirror/autocomplete').Completion[]}
*/
function getVariableSuggestions(variables, builtins) {
return [].concat(
variables.map(v => createVariableSuggestion(v)),
builtins.map(b => createVariableSuggestion(b))
);
}
/**
* @param {import('..').Variable} variable
* @param {number} boost
* @returns {import('@codemirror/autocomplete').Completion}
*/
function createVariableSuggestion(variable, boost) {
if (variable.type === 'function') {
return createFunctionVariable(variable, boost);
}
return {
label: variable.name,
type: 'variable',
info: variable.info,
detail: variable.detail,
boost
};
}
/**
* @param {import('..').Variable} variable
* @param {number} boost
*
* @returns {import('@codemirror/autocomplete').Completion}
*/
function createFunctionVariable(variable, boost) {
const {
name,
info,
detail,
params = []
} = variable;
const paramsWithNames = params.map(({ name, type }, index) => ({
name: name || `param ${index + 1}`,
type
}));
const template = `${name}(${paramsWithNames.map(p => '${' + p.name + '}').join(', ')})`;
const paramsSignature = paramsWithNames.map(({ name, type }) => (
type ? `${name}: ${type}` : name
)).join(', ');
const label = `${name}(${paramsSignature})`;
return autocomplete.snippetCompletion(template, {
label,
type: 'function',
info,
detail,
boost
});
}
/**
* @typedef { import('../core').Variable } Variable
* @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource
*/
/**
* @param { {
* variables?: Variable[],
* builtins?: Variable[]
* } } options
*
* @return { CompletionSource[] }
*/
function completions({ variables = [], builtins = [] }) {
return [
pathExpressionCompletion({ variables }),
variableCompletion({ variables, builtins }),
langFeel.snippets,
...langFeel.keywordCompletions
];
}
/**
* @typedef { 'expression' | 'unaryTests' } Dialect
*/
/**
* @typedef { 'camunda' | undefined } ParserDialect
*/
/**
* @param { {
* dialect?: Dialect,
* parserDialect?: ParserDialect,
* context?: Record<string, any>,
* completions?: import('@codemirror/autocomplete').CompletionSource[]
* } } options
*
* @return { import('@codemirror/language').LanguageSupport }
*/
function language(options) {
return langFeel.feel(options);
}
/**
* @param { import('../core').Variable[] } variables
*
* @return {Record<string, any>}
*/
function createContext(variables) {
return variables.slice().reverse().reduce((context, builtin) => {
context[builtin.name] = () => {};
return context;
}, {});
}
/**
* @typedef { import('../language').Dialect } Dialect
* @typedef { import('../language').ParserDialect } ParserDialect
* @typedef { import('..').Variable } Variable
*/
/**
* @type {Facet<Variable[]>}
*/
const builtinsFacet = state.Facet.define();
/**
* @type {Facet<Variable[]>}
*/
const variablesFacet = state.Facet.define();
/**
* @type {Facet<Dialect>}
*/
const dialectFacet = state.Facet.define();
/**
* @type {Facet<ParserDialect>}
*/
const parserDialectFacet = state.Facet.define();
/**
* @typedef {object} Variable
* @property {string} name name or key of the variable
* @property {string} [info] short information about the variable, e.g. type
* @property {string} [detail] longer description of the variable content
* @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 {'function'|'variable'} [type] type of the variable
* @property {Array<{name: string, type: string}>} [params] function parameters
*/
/**
* @typedef { {
* dialect?: import('../language').Dialect,
* parserDialect?: import('../language').ParserDialect,
* variables?: Variable[],
* builtins?: Variable[]
* } } CoreConfig
*
* @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource
* @typedef { import('@codemirror/state').Extension } Extension
*/
/**
* @param { CoreConfig & { completions?: CompletionSource[] } } config
*
* @return { Extension }
*/
function configure({
dialect = 'expression',
parserDialect,
variables = [],
builtins = [],
completions: completions$1 = completions({ builtins, variables })
}) {
const context = createContext([ ...variables, ...builtins ]);
return [
dialectFacet.of(dialect),
builtinsFacet.of(builtins),
variablesFacet.of(variables),
parserDialectFacet.of(parserDialect),
language({
dialect,
parserDialect,
context,
completions: completions$1
})
];
}
/**
* @param {import('@codemirror/state').EditorState } state
*
* @return { CoreConfig }
*/
function get(state) {
const builtins = state.facet(builtinsFacet)[0];
const variables = state.facet(variablesFacet)[0];
const dialect = state.facet(dialectFacet)[0];
const parserDialect = state.facet(parserDialectFacet)[0];
return {
builtins,
variables,
dialect,
parserDialect
};
}
const domifiedBuiltins = feelBuiltins.camundaBuiltins.map(builtin => ({
...builtin,
info: () => minDom.domify(builtin.info),
}));
/**
* @typedef { import('./core').Variable } Variable
*/
/**
* @typedef { import('./language').Dialect } Dialect
* @typedef { import('./language').ParserDialect } ParserDialect
*/
const coreConf = new state.Compartment();
const placeholderConf = new state.Compartment();
/**
* Creates a FEEL editor in the supplied container
*
* @param {Object} config
* @param {DOMNode} config.container
* @param {Extension[]} [config.extensions]
* @param {Dialect} [config.dialect='expression']
* @param {ParserDialect} [config.parserDialect]
* @param {DOMNode|String} [config.tooltipContainer]
* @param {Function} [config.onChange]
* @param {Function} [config.onKeyDown]
* @param {Function} [config.onLint]
* @param {Boolean} [config.readOnly]
* @param {String} [config.value]
* @param {Variable[]} [config.variables]
* @param {Variable[]} [config.builtins]
*/
function FeelEditor({
extensions: editorExtensions = [],
dialect = 'expression',
parserDialect,
container,
contentAttributes = {},
tooltipContainer,
onChange = () => {},
onKeyDown = () => {},
onLint = () => {},
placeholder = '',
readOnly = false,
value = '',
builtins = domifiedBuiltins,
variables = []
}) {
const changeHandler = view.EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
});
const lintHandler = view.EditorView.updateListener.of((update) => {
const diagnosticEffects = update.transactions
.flatMap(t => t.effects)
.filter(effect => effect.is(lint.setDiagnosticsEffect));
if (!diagnosticEffects.length) {
return;
}
const messages = diagnosticEffects.flatMap(effect => effect.value);
onLint(messages);
});
const keyHandler = view.EditorView.domEventHandlers(
{
keydown: onKeyDown
}
);
if (typeof tooltipContainer === 'string') {
tooltipContainer = document.querySelector(tooltipContainer);
}
const tooltipLayout = tooltipContainer ? view.tooltips({
tooltipSpace: function() {
return tooltipContainer.getBoundingClientRect();
}
}) : [];
const extensions = [
autocomplete.autocompletion(),
coreConf.of(configure({
dialect,
builtins,
variables,
parserDialect
})),
language$1.bracketMatching(),
language$1.indentOnInput(),
autocomplete.closeBrackets(),
view.EditorView.contentAttributes.of(contentAttributes),
changeHandler,
keyHandler,
view.keymap.of([
...commands.defaultKeymap,
]),
linter,
lintHandler,
tooltipLayout,
placeholderConf.of(view.placeholder(placeholder)),
theme,
...editorExtensions
];
if (readOnly) {
extensions.push(view.EditorView.editable.of(false));
}
this._cmEditor = new view.EditorView({
state: state.EditorState.create({
doc: value,
extensions
}),
parent: container
});
return this;
}
/**
* Replaces the content of the Editor
*
* @param {String} value
*/
FeelEditor.prototype.setValue = function(value) {
this._cmEditor.dispatch({
changes: {
from: 0,
to: this._cmEditor.state.doc.length,
insert: value,
}
});
};
/**
* Sets the focus in the editor.
*/
FeelEditor.prototype.focus = function(position) {
const cmEditor = this._cmEditor;
// the Codemirror `focus` method always calls `focus` with `preventScroll`,
// so we have to focus + scroll manually
cmEditor.contentDOM.focus();
cmEditor.focus();
if (typeof position === 'number') {
const end = cmEditor.state.doc.length;
cmEditor.dispatch({ selection: { anchor: position <= end ? position : end } });
}
};
/**
* Returns the current selection ranges. If no text is selected, a single
* range with the start and end index at the cursor position will be returned.
*
* @returns {Object} selection
* @returns {Array} selection.ranges
*/
FeelEditor.prototype.getSelection = function() {
return this._cmEditor.state.selection;
};
/**
* Set variables to be used for autocompletion.
*
* @param {Variable[]} variables
*/
FeelEditor.prototype.setVariables = function(variables) {
const config = get(this._cmEditor.state);
this._cmEditor.dispatch({
effects: [
coreConf.reconfigure(configure({
...config,
variables
}))
]
});
};
/**
* Update placeholder text.
*
* @param {string} placeholder
*/
FeelEditor.prototype.setPlaceholder = function(placeholder) {
this._cmEditor.dispatch({
effects: placeholderConf.reconfigure(view.placeholder(placeholder))
});
};
module.exports = FeelEditor;