UNPKG

@willyterry/feelers

Version:

FEELers grammar and editor for the Lezer parser system. Fixed ESM/CommonJS compatibility.

627 lines (492 loc) 18.8 kB
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 };