@willyterry/feelers
Version:
FEELers grammar and editor for the Lezer parser system. Fixed ESM/CommonJS compatibility.
627 lines (492 loc) • 18.8 kB
JavaScript
import { ExternalTokenizer, LRParser } from '@lezer/lr';
import { styleTags, tags } from '@lezer/highlight';
import { evaluate as evaluate$1 } from 'feelin';
import { closeBrackets } from '@codemirror/autocomplete';
import { defaultKeymap } from '@codemirror/commands';
import { foldNodeProp, LRLanguage, foldInside, LanguageSupport, syntaxTree, bracketMatching, indentOnInput } from '@codemirror/language';
import { linter, setDiagnosticsEffect } from '@codemirror/lint';
import { EditorState } from '@codemirror/state';
import { EditorView, tooltips, keymap, lineNumbers } from '@codemirror/view';
import { parser as parser$2 } from '@lezer/markdown';
import { parser as parser$1 } from 'lezer-feel';
import { parseMixed } from '@lezer/common';
import { cmFeelLinter } from '@bpmn-io/feel-lint';
import { darkTheme, lightTheme } from '@bpmn-io/cm-theme';
// This file was generated by lezer-generator. You probably shouldn't edit it.
const Feel = 1,
FeelBlock = 2,
SimpleTextBlock = 3;
/* global console */
const CHAR_TABLE = {
'{': 123,
'}': 125
};
const isClosingFeelScope = (input, offset = 0) => {
const isReadingCloseCurrent = input.peek(offset) === CHAR_TABLE['}'];
const isReadingCloseAhead = input.peek(offset + 1) === CHAR_TABLE['}'];
const isReadingClose = isReadingCloseCurrent && isReadingCloseAhead;
return isReadingClose || input.peek(offset) === -1;
};
const feelBlock = new ExternalTokenizer((input, stack) => {
let lookAhead = 0;
// check if we haven't reached the end of a templating tag
while (!isClosingFeelScope(input, lookAhead)) { lookAhead++; }
if (lookAhead > 0) {
input.advance(lookAhead);
input.acceptToken(FeelBlock);
}
});
const isClosingTextScope = (input, offset = 0) => {
const isReadingOpenCurrent = input.peek(offset) === CHAR_TABLE['{'];
const isReadingOpenAhead = input.peek(offset + 1) === CHAR_TABLE['{'];
const isReadOpen = isReadingOpenCurrent && isReadingOpenAhead;
return isReadOpen || input.peek(offset) === -1;
};
const simpleTextBlock = new ExternalTokenizer((input, stack) => {
let lookAhead = 0;
// check if we haven't reached the start of a templating tag
while (!isClosingTextScope(input, lookAhead)) { lookAhead++; }
if (lookAhead > 0) {
input.advance(lookAhead);
input.acceptToken(SimpleTextBlock);
}
});
// Anytime this tokenizer is run, simply tag the rest of the input as FEEL
const feel = new ExternalTokenizer((input, stack) => {
let lookAhead = 0;
while (input.peek(lookAhead) !== -1) { lookAhead++; }
if (lookAhead > 0) {
input.advance(lookAhead);
input.acceptToken(Feel);
}
});
const feelersHighlighting = styleTags({
ConditionalSpanner: tags.special(tags.bracket),
ConditionalSpannerClose: tags.special(tags.bracket),
ConditionalSpannerCloseNl: tags.special(tags.bracket),
LoopSpanner: tags.special(tags.bracket),
LoopSpannerClose: tags.special(tags.bracket),
LoopSpannerCloseNl: tags.special(tags.bracket),
EmptyInsert: tags.special(tags.bracket),
Insert: tags.special(tags.bracket),
});
// This file was generated by lezer-generator. You probably shouldn't edit it.
const parser = LRParser.deserialize({
version: 14,
states: "$bOQOaOOOfOXO'#CbOOO`'#Cm'#CmOqOWO'#CcOvOWO'#CfOOO`'#Cp'#CpOOO`'#Ci'#CiO{OaO'#ClO!jOSOOQOOOOOO!oOPO,58{O!tOXO,58|OOO`,58|,58|O!|OQO,58}O#ROQO,59QOOO`-E6g-E6gOOO`1G.g1G.gO#WOPO1G.gOOO`1G.h1G.hO#]OaO1G.iO#qOaO1G.lOOO`7+$R7+$RO$VOPO7+$TO$_OPO7+$WOOO`<<Go<<GoOOO`<<Gr<<Gr",
stateData: "$g~ORUO_WObPOeROgSO^`P~OQYO_ZOc[O~OQ]O~OQ^O~ORUObPOeROgSO^`XW`XX`XZ`X[`X~OPXO~Oc`O~OQaOcbO~OfcO~OfdO~OceO~ORUObPOeROgSOW`PX`P~ORUObPOeROgSOZ`P[`P~OWhOXhO~OZiO[iO~O",
goto: "!ZePPPPPfflPPlPPrPPz!TPP!TXQOVcdXTOVcdUVOcdR_VQXOQfcRgdXUOVcd",
nodeNames: "⚠ Feel FeelBlock SimpleTextBlock Feelers Insert EmptyInsert ConditionalSpanner ConditionalSpannerClose ConditionalSpannerCloseNl LoopSpanner LoopSpannerClose LoopSpannerCloseNl",
maxTerm: 23,
propSources: [feelersHighlighting],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%X~RR!_!`[#o#pa#q#r$r~aO_~~dP#o#pg~lQb~str!P!Q!{~uQ#]#^{#`#a!^~!OP#Y#Z!R~!UPpq!X~!^Oe~~!aP#c#d!d~!gP#c#d!j~!mP#d#e!p~!sPpq!v~!{Og~~#OQ#]#^#U#`#a#u~#XP#Y#Z#[~#_P#q#r#b~#eP#q#r#h~#mPW~YZ#p~#uOX~~#xP#c#d#{~$OP#c#d$R~$UP#d#e$X~$[P#q#r$_~$bP#q#r$e~$jPZ~YZ$m~$rO[~R$uP#q#r$xR%PPcPfQYZ%SQ%XOfQ",
tokenizers: [0, 1, feel, feelBlock, simpleTextBlock],
topRules: {"Feelers":[0,4]},
tokenPrec: 0
});
function buildSimpleTree(parseTree, templateString) {
const stack = [ { children: [] } ];
const isLeafNode = (node) => [ 'SimpleTextBlock', 'Feel', 'FeelBlock' ].includes(node.type.name);
parseTree.iterate({
enter: (node, pos, type) => {
const nodeRepresentation = {
name: node.type.name,
children: []
};
if (isLeafNode(node)) {
nodeRepresentation.content = templateString.slice(node.from, node.to);
}
stack.push(nodeRepresentation);
},
leave: (node, pos, type) => {
const result = stack.pop();
const parent = stack[stack.length - 1];
result.parent = parent;
parent.children.push(result);
}
});
return stack[0].children[0];
}
/**
* @typedef {object} EvaluationOptions
* @property {boolean} [debug=false] - whether to enable debug mode, which displays errors inline instead of throwing them
* @property {function} [buildDebugString=(e) => `{{ ${e.message.toLowerCase()} }}`] - function that takes an error and returns the string to display in debug mode
* @property {boolean} [strict=false] - whether to expect strict data types out of our FEEL expression, e.g. boolean for conditionals
* @property {function} [sanitizer] - function to sanitize individual FEEL evaluation results
*/
/**
* @param {string} templateString - the template string to evaluate
* @param {object} [context={}] - the context object to evaluate the template string against
* @param {EvaluationOptions} [options={}] - options to configure the evaluation
* @return {string} the evaluated template string
*/
const evaluate = (templateString, context = {}, options = {}) => {
const {
debug = false,
strict = false,
buildDebugString = (e) => `{{ ${e.message.toLowerCase()} }}`,
sanitizer
} = options;
const parseTree = parser.parse(templateString);
const simpleTreeRoot = buildSimpleTree(parseTree, templateString);
const evaluateNode = buildNodeEvaluator({ debug, strict, buildDebugString, sanitizer });
return evaluateNode(simpleTreeRoot, enhanceContext(context, null));
};
/**
* @param {EvaluationOptions} options - options to configure the evaluation
* @return {function} a function that takes a node and context and evaluates it
*/
const buildNodeEvaluator = (options) => {
const {
debug,
strict,
buildDebugString,
sanitizer
} = options;
const errorHandler = (error) => {
if (debug) {
return buildDebugString(error);
}
throw error;
};
const evaluateNodeValue = (node, context = {}) => {
switch (node.name) {
case 'SimpleTextBlock':
return node.content;
case 'Insert': {
const feel = node.children[0].content;
try {
const result = evaluate$1(`string(${feel})`, context);
return sanitizer ? sanitizer(result) : result;
}
catch {
return errorHandler(new Error(`FEEL expression ${feel} couldn't be evaluated`));
}
}
case 'EmptyInsert':
return '';
case 'Feel':
case 'FeelBlock': {
const feel = node.content;
try {
const result = evaluate$1(`string(${feel})`, context);
return sanitizer ? sanitizer(result) : result;
}
catch (e) {
return errorHandler(new Error(`FEEL expression ${feel} couldn't be evaluated`));
}
}
case 'Feelers':
return node.children.map(child => evaluateNode(child, context)).join('');
case 'ConditionalSpanner': {
const feel = node.children[0].content;
let shouldRender;
try {
shouldRender = evaluate$1(feel, context);
}
catch {
return errorHandler(new Error(`FEEL expression ${feel} couldn't be evaluated`));
}
if (strict && typeof(shouldRender) !== 'boolean') {
return errorHandler(new Error(`FEEL expression ${feel} expected to evaluate to a boolean`));
}
if (shouldRender) {
const children = node.children.slice(1, node.children.length - 1);
const innerRender = children.map(child => evaluateNode(child, context)).join('');
const closeNode = node.children[node.children.length - 1];
const shouldAddNewline = closeNode.name.endsWith('Nl') && !innerRender.endsWith('\n');
return innerRender + (shouldAddNewline ? '\n' : '');
}
return '';
}
case 'LoopSpanner': {
const feel = node.children[0].content;
let loopArray;
try {
loopArray = evaluate$1(feel, context);
}
catch {
return errorHandler(new Error(`FEEL expression ${feel} couldn't be evaluated`));
}
if (!Array.isArray(loopArray)) {
if (strict) {
return errorHandler(new Error(`FEEL expression ${feel} expected to evaluate to an array`));
}
// if not strict, we treat undefined/null as an empty array
else if (loopArray === undefined || loopArray === null) {
loopArray = [];
}
// if not strict, we treat a single item as an array with one item
else {
loopArray = [ loopArray ];
}
}
const childrenToLoop = node.children.slice(1, node.children.length - 1);
const evaluateChildren = (arrayElement, parentContext) => {
const childContext = enhanceContext(arrayElement, parentContext);
return childrenToLoop.map(child => evaluateNode(child, childContext)).join('');
};
const innerRender = loopArray.map(arrayElement => evaluateChildren(arrayElement, context)).join('');
const closeNode = node.children[node.children.length - 1];
const shouldAddNewline = closeNode.name.endsWith('Nl') && !innerRender.endsWith('\n');
return innerRender + (shouldAddNewline ? '\n' : '');
}}
};
const evaluateNode = (node, context = {}) => {
try {
return evaluateNodeValue(node, context);
} catch (error) {
return errorHandler(error);
}
};
return evaluateNode;
};
const enhanceContext = (context, parentContext) => {
if (typeof(context) === 'object') {
return { this: context, parent: parentContext, ...context, _this_: context, _parent_: parentContext };
}
return { this: context, parent: parentContext, _this_: context, _parent_: parentContext };
};
const foldMetadata = {
ConditionalSpanner: foldInside,
LoopSpanner: foldInside
};
function createMixedLanguage(hostLanguage = null) {
const _mixedParser = parser.configure({
wrap: parseMixed(node => {
if (node.name == 'Feel' || node.name == 'FeelBlock') {
return { parser: parser$1 };
}
if (hostLanguage && node.name == 'SimpleTextBlock') {
return { parser: hostLanguage };
}
return null;
}),
props: [
foldNodeProp.add(foldMetadata)
]
});
return LRLanguage.define({ parser: _mixedParser });
}
const createFeelersLanguageSupport = (hostLanguageParser) => new LanguageSupport(createMixedLanguage(hostLanguageParser), []);
/**
* Create warnings for empty inserts in the given tree.
*
* @param {Tree} syntaxTree
* @returns {LintMessage[]} array of syntax errors
*/
function lintEmptyInserts(syntaxTree) {
const lintMessages = [];
syntaxTree.iterate({
enter: node => {
if (node.type.name === 'EmptyInsert') {
lintMessages.push(
{
from: node.from,
to: node.to,
severity: 'warning',
message: 'this insert is empty and will be ignored',
type: 'emptyInsert'
}
);
}
}
});
return lintMessages;
}
/**
* Generates lint messages for the given syntax tree.
*
* @param {Tree} syntaxTree
* @returns {LintMessage[]} array of all lint messages
*/
function lintAll(syntaxTree) {
const lintMessages = [
...lintEmptyInserts(syntaxTree)
];
return lintMessages;
}
/**
* CodeMirror extension that provides linting for FEEL expressions.
*
* @param {EditorView} editorView
* @returns {Source} CodeMirror linting source
*/
function cmFeelersLinter() {
const lintFeel = cmFeelLinter();
return editorView => {
const feelMessages = lintFeel(editorView);
// don't lint if the Editor is empty
if (editorView.state.doc.length === 0) {
return [];
}
const tree = syntaxTree(editorView.state);
const feelersMessages = lintAll(tree);
return [
...feelMessages,
...feelersMessages.map(message => ({
...message,
source: 'feelers linter'
}))
];
};
}
var lint = linter(cmFeelersLinter());
/**
* Creates a Feelers editor in the supplied container.
*
* @param {Object} config Configuration options for the Feelers editor.
* @param {DOMNode} [config.container] The DOM node that will contain the editor.
* @param {DOMNode|String} [config.tooltipContainer] The DOM node or CSS selector string for the tooltip container.
* @param {String} [config.hostLanguage] The host language for the editor (e.g., 'markdown').
* @param {Object} [config.hostLanguageParser] A custom parser for the host language.
* @param {Function} [config.onChange] Callback function that is called when the editor's content changes.
* @param {Function} [config.onKeyDown] Callback function that is called when a key is pressed within the editor.
* @param {Function} [config.onLint] Callback function that is called when linting messages are available.
* @param {Object} [config.contentAttributes] Additional attributes to set on the editor's content element.
* @param {Boolean} [config.readOnly] Set to true to make the editor read-only.
* @param {String} [config.value] Initial value of the editor.
* @param {Boolean} [config.enableGutters] Set to true to enable gutter decorations (e.g., line numbers).
* @param {Boolean} [config.singleLine] Set to true to limit the editor to a single line.
* @param {Boolean} [config.lineWrap] Set to true to enable line wrapping.
* @param {Boolean} [config.darkMode] Set to true to use the dark theme for the editor.
*
* @returns {Object} editor An instance of the FeelersEditor class.
*/
function FeelersEditor({
container,
tooltipContainer,
hostLanguage,
hostLanguageParser,
onChange = () => { },
onKeyDown = () => { },
onLint = () => { },
contentAttributes = { },
readOnly = false,
value = '',
enableGutters = false,
singleLine = false,
lineWrap = false,
darkMode = false
}) {
const changeHandler = EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
});
const lintHandler = EditorView.updateListener.of((update) => {
const diagnosticEffects = update.transactions
.flatMap(t => t.effects)
.filter(effect => effect.is(setDiagnosticsEffect));
if (!diagnosticEffects.length) {
return;
}
const messages = diagnosticEffects.flatMap(effect => effect.value);
onLint(messages);
});
const contentAttributesExtension = EditorView.contentAttributes.of(contentAttributes);
const keyHandler = EditorView.domEventHandlers(
{
keydown: onKeyDown
}
);
if (typeof tooltipContainer === 'string') {
// eslint-disable-next-line no-undef
tooltipContainer = document.querySelector(tooltipContainer);
}
const tooltipLayout = tooltipContainer ? tooltips({
tooltipSpace: function() {
return tooltipContainer.getBoundingClientRect();
}
}) : [];
const _getHostLanguageParser = (hostLanguage) => {
switch (hostLanguage) {
case 'markdown':
return parser$2;
default:
return null;
}
};
const feelersLanguageSupport = createFeelersLanguageSupport(hostLanguageParser || hostLanguage && _getHostLanguageParser(hostLanguage));
const extensions = [
bracketMatching(),
changeHandler,
contentAttributesExtension,
closeBrackets(),
indentOnInput(),
keyHandler,
keymap.of([
...defaultKeymap,
]),
feelersLanguageSupport,
lint,
lintHandler,
tooltipLayout,
darkMode ? darkTheme : lightTheme,
...(enableGutters ? [
// todo: adjust folding boundaries first foldGutter(),
lineNumbers()
] : []),
...(singleLine ? [
EditorState.transactionFilter.of(tr => tr.newDoc.lines > 1 ? [] : tr)
] : []),
...(lineWrap ? [
EditorView.lineWrapping
] : [])
];
if (readOnly) {
extensions.push(EditorView.editable.of(false));
}
if (singleLine && value) {
value = value.toString().split('\n')[0];
}
this._cmEditor = new EditorView({
state: EditorState.create({
doc: value,
extensions: extensions
}),
parent: container
});
return this;
}
/**
* Replaces the content of the Editor
*
* @param {String} value
*/
FeelersEditor.prototype.setValue = function(value) {
this._cmEditor.dispatch({
changes: {
from: 0,
to: this._cmEditor.state.doc.length,
insert: value,
}
});
};
/**
* Sets the focus in the editor.
*/
FeelersEditor.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
*/
FeelersEditor.prototype.getSelection = function() {
return this._cmEditor.state.selection;
};
export { FeelersEditor, buildSimpleTree, evaluate, parser };