From 299c4d93597b3065e6a1017ebe692cde66fc5e39 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 11 Oct 2021 01:03:21 +0200 Subject: feat(web): experiment with Lezer parser --- language-web/build.gradle | 12 ++- language-web/package.json | 5 +- language-web/src/main/js/editor/EditorArea.tsx | 49 ++++++--- language-web/src/main/js/editor/EditorParent.ts | 22 +++- language-web/src/main/js/editor/EditorStore.ts | 2 + language-web/src/main/js/editor/folding.ts | 97 +++++++++++++++++ language-web/src/main/js/editor/indentation.ts | 84 +++++++++++++++ language-web/src/main/js/editor/problem.grammar | 117 +++++++++++++++++++++ .../src/main/js/editor/problemLanguageSupport.ts | 82 +++++++++++++++ language-web/src/main/js/index.tsx | 6 +- language-web/tsconfig.json | 4 +- language-web/yarn.lock | 10 +- 12 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 language-web/src/main/js/editor/folding.ts create mode 100644 language-web/src/main/js/editor/indentation.ts create mode 100644 language-web/src/main/js/editor/problem.grammar create mode 100644 language-web/src/main/js/editor/problemLanguageSupport.ts (limited to 'language-web') diff --git a/language-web/build.gradle b/language-web/build.gradle index c467c019..1f1457d6 100644 --- a/language-web/build.gradle +++ b/language-web/build.gradle @@ -35,14 +35,24 @@ frontend { yarnEnabled = true yarnVersion = project.ext.yarnVersion yarnInstallDirectory = file("${rootDir}/.gradle/yarn") - assembleScript = 'run assemble' + assembleScript = 'run assemble:webpack' } def installFrontend = tasks.named('installFrontend') +def generateLezerGrammar = tasks.register('generateLezerGrammar', RunNpmYarn) { + dependsOn installFrontend + inputs.file('src/main/js/editor/problem.grammar') + inputs.files('package.json', 'yarn.lock') + outputs.file "${buildDir}/generated/sources/lezer/problem.ts" + outputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts" + script = 'run assemble:lezer' +} + def assembleFrontend = tasks.named('assembleFrontend') assembleFrontend.configure { dependsOn generateXtextLanguage + dependsOn generateLezerGrammar inputs.dir 'src/main/css' inputs.dir 'src/main/html' inputs.dir 'src/main/js' diff --git a/language-web/package.json b/language-web/package.json index d55968ee..78b080ee 100644 --- a/language-web/package.json +++ b/language-web/package.json @@ -4,7 +4,8 @@ "description": "Web frontend for VIATRA-Generator", "main": "index.js", "scripts": { - "assemble": "webpack --node-env production", + "assemble:lezer": "lezer-generator src/main/js/editor/problem.grammar -o build/generated/sources/lezer/problem.ts", + "assemble:webpack": "webpack --node-env production", "serve": "webpack serve --node-env development --hot", "check": "yarn run check:eslint && yarn run check:stylelint", "check:eslint": "eslint .", @@ -40,6 +41,7 @@ "eslint-plugin-sonarjs": "^0.10.0", "html-webpack-plugin": "^5.3.2", "image-webpack-loader": "^8.0.1", + "@lezer/generator": "^0.15.2", "magic-comments-loader": "^1.4.1", "mini-css-extract-plugin": "^2.3.0", "@principalstudio/html-webpack-inject-preload": "^1.2.7", @@ -81,6 +83,7 @@ "@emotion/styled": "^11.3.0", "@fontsource/jetbrains-mono": "^4.5.0", "@fontsource/roboto": "^4.5.0", + "@lezer/lr": "^0.15.4", "@mui/material": "5.0.2", "@mui/icons-material": "5.0.1", "jquery": "^3.6.0", diff --git a/language-web/src/main/js/editor/EditorArea.tsx b/language-web/src/main/js/editor/EditorArea.tsx index 58d65184..460005ce 100644 --- a/language-web/src/main/js/editor/EditorArea.tsx +++ b/language-web/src/main/js/editor/EditorArea.tsx @@ -1,8 +1,14 @@ import { Command, EditorView } from '@codemirror/view'; import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; import { closeLintPanel, openLintPanel } from '@codemirror/lint'; + import { observer } from 'mobx-react-lite'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { EditorParent } from './EditorParent'; import { getLogger } from '../logging'; @@ -11,36 +17,49 @@ import { useRootStore } from '../RootStore'; const log = getLogger('EditorArea'); function usePanel( - label: string, + panelId: string, stateToSet: boolean, editorView: EditorView | null, openCommand: Command, closeCommand: Command, + closeCallback: () => void, ) { const [cachedViewState, setCachedViewState] = useState(false); useEffect(() => { if (editorView === null || cachedViewState === stateToSet) { return; } - const success = stateToSet ? openCommand(editorView) : closeCommand(editorView); - if (!success) { - log.error( - 'Failed to synchronize', - label, - 'panel state - store state:', - cachedViewState, - 'view state:', - stateToSet, - ); + if (stateToSet) { + openCommand(editorView); + const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`; + const closeButton = editorView.dom.querySelector(buttonQuery); + if (closeButton) { + log.debug('Addig close button callback to', panelId, 'panel'); + // We must remove the event listener added by CodeMirror from the button + // that dispatches a transaction without going through `EditorStorre`. + // Cloning a DOM node removes event listeners, + // see https://stackoverflow.com/a/9251864 + const closeButtonWithoutListeners = closeButton.cloneNode(true); + closeButtonWithoutListeners.addEventListener('click', (event) => { + closeCallback(); + event.preventDefault(); + }); + closeButton.replaceWith(closeButtonWithoutListeners); + } else { + log.error('Opened', panelId, 'panel has no close button'); + } + } else { + closeCommand(editorView); } setCachedViewState(stateToSet); }, [ stateToSet, editorView, cachedViewState, - label, + panelId, openCommand, closeCommand, + closeCallback, ]); return setCachedViewState; } @@ -56,14 +75,16 @@ export const EditorArea = observer(() => { editorViewState, openSearchPanel, closeSearchPanel, + useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), ); const setLintPanelOpen = usePanel( - 'lint', + 'panel-lint', editorStore.showLintPanel, editorViewState, openLintPanel, closeLintPanel, + useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), ); useEffect(() => { diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts index bf67522b..316c5072 100644 --- a/language-web/src/main/js/editor/EditorParent.ts +++ b/language-web/src/main/js/editor/EditorParent.ts @@ -49,13 +49,29 @@ export const EditorParent = styled('div')(({ theme }) => ({ background: theme.palette.background.paper, borderTop: `1px solid ${theme.palette.divider}`, 'button[name="close"]': { - // HACK We can't hook the panel close button to go through `EditorStore`, - // so we hide it altogether. - display: 'none', + color: theme.palette.text.secondary, + cursor: 'pointer', }, }, + '.cm-foldPlaceholder': { + background: theme.palette.background.paper, + borderColor: theme.palette.text.disabled, + color: theme.palette.text.secondary, + }, '.cmt-comment': { fontVariant: 'italic', color: theme.palette.text.disabled, }, + '.cmt-number': { + color: '#6188a6', + }, + '.cmt-keyword': { + color: theme.palette.primary.main, + }, + '.cmt-typeName, .cmt-atom': { + color: theme.palette.text.primary, + }, + '.cmt-variableName': { + color: '#c8ae9d', + }, })); diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 326c02a1..eb358338 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts @@ -39,6 +39,7 @@ import { } from 'mobx'; import { getLogger } from '../logging'; +import { problemLanguageSupport } from './problemLanguageSupport'; import type { ThemeStore } from '../theme/ThemeStore'; const log = getLogger('EditorStore'); @@ -105,6 +106,7 @@ export class EditorStore { ...searchKeymap, ...defaultKeymap, ]), + problemLanguageSupport(), ], }); reaction( diff --git a/language-web/src/main/js/editor/folding.ts b/language-web/src/main/js/editor/folding.ts new file mode 100644 index 00000000..54c7294d --- /dev/null +++ b/language-web/src/main/js/editor/folding.ts @@ -0,0 +1,97 @@ +import { EditorState } from '@codemirror/state'; +import type { SyntaxNode } from '@lezer/common'; + +export type FoldRange = { from: number, to: number }; + +/** + * Folds a block comment between its delimiters. + * + * @param node the node to fold + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldBlockComment(node: SyntaxNode): FoldRange { + return { + from: node.from + 2, + to: node.to - 2, + }; +} + +/** + * Folds a declaration after the first element if it appears on the opening line, + * otherwise folds after the opening keyword. + * + * @example + * First element on the opening line: + * ``` + * scope Family = 1, + * Person += 5..10. + * ``` + * becomes + * ``` + * scope Family = 1,[...]. + * ``` + * + * @example + * First element not on the opening line: + * ``` + * scope Family + * = 1, + * Person += 5..10. + * ``` + * becomes + * ``` + * scope [...]. + * ``` + * + * @param node the node to fold + * @param state the editor state + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null { + const { firstChild: open, lastChild: close } = node; + if (open === null || close === null) { + return null; + } + const { cursor } = open; + const lineEnd = state.doc.lineAt(open.from).to; + let foldFrom = open.to; + while (cursor.next() && cursor.from < lineEnd) { + if (cursor.type.name === ',') { + foldFrom = cursor.to; + break; + } + } + return { + from: foldFrom, + to: close.from, + }; +} + +/** + * Folds a node only if it has at least one sibling of the same type. + * + * The folding range will be the entire `node`. + * + * @param node the node to fold + * @returns the folding range or `null` is there is nothing to fold + */ +export function foldConjunction(node: SyntaxNode): FoldRange | null { + const { parent } = node; + if (parent === null) { + return null; + } + const { cursor } = parent; + let nConjunctions = 0; + while (cursor.next()) { + if (cursor.type === node.type) { + nConjunctions += 1; + } + if (nConjunctions >= 2) { + return { + from: node.from, + to: node.to, + }; + } + } + return null; +} diff --git a/language-web/src/main/js/editor/indentation.ts b/language-web/src/main/js/editor/indentation.ts new file mode 100644 index 00000000..b2f0134b --- /dev/null +++ b/language-web/src/main/js/editor/indentation.ts @@ -0,0 +1,84 @@ +import { TreeIndentContext } from '@codemirror/language'; + +/** + * Finds the `from` of first non-skipped token, if any, + * after the opening keyword in the first line of the declaration. + * + * Based on + * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246 + * + * @param context the indentation context + * @returns the alignment or `null` if there is no token after the opening keyword + */ +function findAlignmentAfterOpening(context: TreeIndentContext): number | null { + const { + node: tree, + simulatedBreak, + } = context; + const openingToken = tree.childAfter(tree.from); + if (openingToken === null) { + return null; + } + const openingLine = context.state.doc.lineAt(openingToken.from); + const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from + ? openingLine.to + : Math.min(openingLine.to, simulatedBreak); + const { cursor } = openingToken; + while (cursor.next() && cursor.from < lineEnd) { + if (!cursor.type.isSkipped) { + return cursor.from; + } + } + return null; +} + +/** + * Indents text after declarations by a single unit if it begins on a new line, + * otherwise it aligns with the text after the declaration. + * + * Based on + * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275 + * + * @example + * Result with no hanging indent (indent unit = 2 spaces, units = 1): + * ``` + * scope + * Family = 1, + * Person += 5..10. + * ``` + * + * @example + * Result with hanging indent: + * ``` + * scope Family = 1, + * Person += 5..10. + * ``` + * + * @param context the indentation context + * @param units the number of units to indent + * @returns the desired indentation level + */ +function indentDeclarationStrategy(context: TreeIndentContext, units: number): number { + const alignment = findAlignmentAfterOpening(context); + if (alignment !== null) { + return context.column(alignment); + } + return context.baseIndent + units * context.unit; +} + +export function indentBlockComment(): number { + // Do not indent. + return -1; +} + +export function indentDeclaration(context: TreeIndentContext): number { + return indentDeclarationStrategy(context, 1); +} + +export function indentPredicate(context: TreeIndentContext): number { + const clauseIndent = indentDeclarationStrategy(context, 1); + if (/^\s+(;|\.)/.exec(context.textAfter) !== null) { + return clauseIndent - context.unit; + } + return clauseIndent; +} diff --git a/language-web/src/main/js/editor/problem.grammar b/language-web/src/main/js/editor/problem.grammar new file mode 100644 index 00000000..c64402b0 --- /dev/null +++ b/language-web/src/main/js/editor/problem.grammar @@ -0,0 +1,117 @@ + +@top Problem { statement* } + +statement { + ProblemDeclaration { + kw<"problem"> QualifiedName "." + } | + ClassDefinition { + kw<"abstract">? kw<"class"> RelationName + (ClassBody { "{" ReferenceDeclaration* "}" } | ".") + } | + EnumDefinition { + kw<"enum"> RelationName + (EnumBody { "{" sep<",", UniqueNodeName> "}" } | ".") + } | + PredicateDefinition { + (kw<"error"> kw<"pred">? | kw<"pred">) RelationName ParameterList? + PredicateBody { ("<->" sep)? "." } + } | + Assertion { + kw<"default">? (NotOp | UnknownOp)? RelationName + ParameterList (":" LogicValue)? "." + } | + UniqueDeclaration { + kw<"unique"> sep<",", UniqueNodeName> "." + } | + ScopeDeclaration { + kw<"scope"> sep<",", ScopeElement> "." + } +} + +ReferenceDeclaration { + (kw<"refers"> | kw<"contains">)? + RelationName + RelationName + ( "[" Multiplicity? "]" )? + (kw<"opposite"> RelationName)? + ";"? +} + +Parameter { RelationName? VariableName } + +Conjunction { sep1<",", Literal> } + +OrOp { ";" } + +Literal { NotOp? Atom } + +Atom { RelationName ParameterList? } + +Argument { VariableName | Real } + +AssertionArgument { NodeName | StarArgument | Real } + +LogicValue { + kw<"true"> | kw<"false"> | kw<"unknown"> | kw<"error"> +} + +ScopeElement { RelationName ("=" | "+=") Multiplicity } + +Multiplicity { (IntMult "..")? (IntMult | StarMult)} + +RelationName { QualifiedName } + +UniqueNodeName { QualifiedName } + +VariableName { QualifiedName } + +NodeName { QualifiedName } + +QualifiedName { identifier ("::" identifier)* } + +kw { @specialize[@name={term}] } + +ParameterList { "(" sep<",", content> ")" } + +sep { sep1? } + +sep1 { content (separator content?)* } + +@skip { LineComment | BlockComment | whitespace } + +@tokens { + whitespace { std.whitespace+ } + + LineComment { ("//" | "%") ![\n]* } + + BlockComment { "/*" blockCommentRest } + + blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar } + + blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest } + + @precedence { BlockComment, LineComment } + + identifier { $[A-Za-z_] $[a-zA-Z0-9_]* } + + int { $[0-9]+ } + + IntMult { int } + + StarMult { "*" } + + Real { "-"? (exponential | int ("." (int | exponential))?) } + + exponential { int ("e" | "E") ("+" | "-")? int } + + NotOp { "!" } + + UnknownOp { "?" } + + StarArgument { "*" } + + "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" +} + +@detectDelim diff --git a/language-web/src/main/js/editor/problemLanguageSupport.ts b/language-web/src/main/js/editor/problemLanguageSupport.ts new file mode 100644 index 00000000..2bf7c7a4 --- /dev/null +++ b/language-web/src/main/js/editor/problemLanguageSupport.ts @@ -0,0 +1,82 @@ +import { styleTags, tags as t } from '@codemirror/highlight'; +import { + foldInside, + foldNodeProp, + indentNodeProp, + LanguageSupport, + LRLanguage, +} from '@codemirror/language'; +import { LRParser } from '@lezer/lr'; + +import { parser } from '../../../../build/generated/sources/lezer/problem'; +import { + foldBlockComment, + foldConjunction, + foldDeclaration, +} from './folding'; +import { + indentBlockComment, + indentDeclaration, + indentPredicate, +} from './indentation'; + +const parserWithMetadata = (parser as LRParser).configure({ + props: [ + styleTags({ + LineComment: t.lineComment, + BlockComment: t.blockComment, + 'problem class enum pred unique scope': t.definitionKeyword, + 'abstract refers contains opposite error default': t.modifier, + 'true false unknown error': t.keyword, + NotOp: t.keyword, + UnknownOp: t.keyword, + OrOp: t.keyword, + StarArgument: t.keyword, + 'IntMult StarMult Real': t.number, + StarMult: t.number, + 'RelationName/QualifiedName': t.typeName, + 'UniqueNodeName/QualifiedName': t.atom, + 'VariableName/QualifiedName': t.variableName, + '{ }': t.brace, + '( )': t.paren, + '[ ]': t.squareBracket, + '. .. , :': t.separator, + '<->': t.definitionOperator, + }), + indentNodeProp.add({ + ProblemDeclaration: indentDeclaration, + UniqueDeclaration: indentDeclaration, + ScopeDeclaration: indentDeclaration, + PredicateBody: indentPredicate, + BlockComment: indentBlockComment, + }), + foldNodeProp.add({ + ClassBody: foldInside, + EnumBody: foldInside, + ParameterList: foldInside, + PredicateBody: foldInside, + Conjunction: foldConjunction, + UniqueDeclaration: foldDeclaration, + ScopeDeclaration: foldDeclaration, + BlockComment: foldBlockComment, + }), + ], +}); + +const problemLanguage = LRLanguage.define({ + parser: parserWithMetadata, + languageData: { + commentTokens: { + block: { + open: '/*', + close: '*/', + }, + line: '%', + }, + indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.)$/, + }, +}); + +export function problemLanguageSupport(): LanguageSupport { + return new LanguageSupport(problemLanguage); +} diff --git a/language-web/src/main/js/index.tsx b/language-web/src/main/js/index.tsx index 66ad1f28..1b24eadb 100644 --- a/language-web/src/main/js/index.tsx +++ b/language-web/src/main/js/index.tsx @@ -25,7 +25,11 @@ enum TaxStatus { % A child cannot have any dependents. error invalidTaxStatus(Person p) <-> - taxStatus(p, child), children(p, _q). + taxStatus(p, child), + children(p, _q) +; taxStatus(p, retired), + parent(p, q), + !taxStatus(q, retired). unique family. Family(family). diff --git a/language-web/tsconfig.json b/language-web/tsconfig.json index 7f43a8b5..d028a64f 100644 --- a/language-web/tsconfig.json +++ b/language-web/tsconfig.json @@ -4,7 +4,7 @@ "module": "ES2020", "moduleResolution": "node", "paths": { - "xtext/*": ["./src/main/js/xtext/*"] + "xtext/*": ["./src/main/js/xtext/*"], }, "esModuleInterop": true, "allowSyntheticDefaultImports": true, @@ -16,5 +16,5 @@ "noEmit": true }, "include": ["./src/main/js/**/*"], - "exclude": ["./src/main/js/xtext/**/*"] + "exclude": ["./build/generated/sources/lezer/*"] } diff --git a/language-web/yarn.lock b/language-web/yarn.lock index 51c551a7..360c5be3 100644 --- a/language-web/yarn.lock +++ b/language-web/yarn.lock @@ -1305,7 +1305,15 @@ resolved "https://registry.yarnpkg.com/@lezer/common/-/common-0.15.7.tgz#8b445dae9777f689783132cf490770ece3c03d7b" integrity sha512-Rw8TDJnBzZnkyzIXs1Tmmd241FrBLJBj8gkdy3y0joGFb8Z4I/joKEsR+gv1pb13o1TMsZxm3fmP+d/wPt2CTQ== -"@lezer/lr@^0.15.0": +"@lezer/generator@^0.15.2": + version "0.15.2" + resolved "https://registry.yarnpkg.com/@lezer/generator/-/generator-0.15.2.tgz#10fa8fab58a561c2bd2a27d7b4f20b1080c6cb6c" + integrity sha512-nxY6TTj0ZAcAvg1zEeaZnt1xODdyPhD0lTaPOgcGOVFHhwwx0Oz7CxZB7Rh+xRCXFr5kJWDtM1uXPp80UZjhAg== + dependencies: + "@lezer/common" "^0.15.0" + "@lezer/lr" "^0.15.0" + +"@lezer/lr@^0.15.0", "@lezer/lr@^0.15.4": version "0.15.4" resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-0.15.4.tgz#634670d7224040fddac1370af01211eecd9ac0a0" integrity sha512-vwgG80sihEGJn6wJp6VijXrnzVai/KPva/OzYKaWvIx0IiXKjoMQ8UAwcgpSBwfS4Fbz3IKOX/cCNXU3r1FvpQ== -- cgit v1.2.3-54-g00ecf