From a96c52b21e7e590bbdd70b80896780a446fa2e8b Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 13 Dec 2021 02:07:04 +0100 Subject: build: separate module for frontend This allows us to simplify the webpack configuration and the gradle build scripts. --- package.json | 2 +- settings.gradle | 1 + subprojects/frontend/.eslintrc.js | 39 +++ subprojects/frontend/.stylelintrc.js | 15 + subprojects/frontend/build.gradle | 101 ++++++ subprojects/frontend/package.json | 102 ++++++ subprojects/frontend/src/App.tsx | 60 ++++ subprojects/frontend/src/RootStore.tsx | 39 +++ subprojects/frontend/src/editor/EditorArea.tsx | 152 +++++++++ subprojects/frontend/src/editor/EditorButtons.tsx | 98 ++++++ subprojects/frontend/src/editor/EditorParent.ts | 205 ++++++++++++ subprojects/frontend/src/editor/EditorStore.ts | 289 ++++++++++++++++ subprojects/frontend/src/editor/GenerateButton.tsx | 44 +++ .../frontend/src/editor/decorationSetExtension.ts | 39 +++ subprojects/frontend/src/editor/findOccurrences.ts | 35 ++ .../frontend/src/editor/semanticHighlighting.ts | 24 ++ subprojects/frontend/src/global.d.ts | 11 + subprojects/frontend/src/index.html | 16 + subprojects/frontend/src/index.scss | 16 + subprojects/frontend/src/index.tsx | 69 ++++ subprojects/frontend/src/language/folding.ts | 115 +++++++ subprojects/frontend/src/language/indentation.ts | 87 +++++ subprojects/frontend/src/language/problem.grammar | 149 +++++++++ .../src/language/problemLanguageSupport.ts | 92 ++++++ subprojects/frontend/src/language/props.ts | 7 + subprojects/frontend/src/theme/EditorTheme.ts | 47 +++ subprojects/frontend/src/theme/ThemeProvider.tsx | 15 + subprojects/frontend/src/theme/ThemeStore.ts | 64 ++++ .../frontend/src/themeVariables.module.scss | 9 + subprojects/frontend/src/themes.scss | 38 +++ .../frontend/src/utils/ConditionVariable.ts | 64 ++++ subprojects/frontend/src/utils/PendingTask.ts | 60 ++++ subprojects/frontend/src/utils/Timer.ts | 33 ++ subprojects/frontend/src/utils/logger.ts | 49 +++ .../frontend/src/xtext/ContentAssistService.ts | 219 +++++++++++++ .../frontend/src/xtext/HighlightingService.ts | 37 +++ .../frontend/src/xtext/OccurrencesService.ts | 127 +++++++ subprojects/frontend/src/xtext/UpdateService.ts | 363 +++++++++++++++++++++ .../frontend/src/xtext/ValidationService.ts | 39 +++ subprojects/frontend/src/xtext/XtextClient.ts | 86 +++++ .../frontend/src/xtext/XtextWebSocketClient.ts | 362 ++++++++++++++++++++ subprojects/frontend/src/xtext/xtextMessages.ts | 40 +++ .../frontend/src/xtext/xtextServiceResults.ts | 112 +++++++ subprojects/frontend/tsconfig.json | 18 + subprojects/frontend/tsconfig.sonar.json | 16 + subprojects/frontend/webpack.config.js | 164 ++++++++++ subprojects/language-web/.editorconfig | 5 - subprojects/language-web/.eslintrc.js | 40 --- subprojects/language-web/.stylelintrc.js | 15 - subprojects/language-web/build.gradle | 115 +------ subprojects/language-web/package.json | 103 ------ subprojects/language-web/src/main/css/index.scss | 16 - .../src/main/css/themeVariables.module.scss | 9 - subprojects/language-web/src/main/css/themes.scss | 38 --- subprojects/language-web/src/main/html/index.html | 16 - subprojects/language-web/src/main/js/App.tsx | 60 ---- subprojects/language-web/src/main/js/RootStore.tsx | 39 --- .../language-web/src/main/js/editor/EditorArea.tsx | 152 --------- .../src/main/js/editor/EditorButtons.tsx | 98 ------ .../src/main/js/editor/EditorParent.ts | 205 ------------ .../language-web/src/main/js/editor/EditorStore.ts | 289 ---------------- .../src/main/js/editor/GenerateButton.tsx | 44 --- .../src/main/js/editor/decorationSetExtension.ts | 39 --- .../src/main/js/editor/findOccurrences.ts | 35 -- .../src/main/js/editor/semanticHighlighting.ts | 24 -- subprojects/language-web/src/main/js/global.d.ts | 11 - subprojects/language-web/src/main/js/index.tsx | 69 ---- .../language-web/src/main/js/language/folding.ts | 115 ------- .../src/main/js/language/indentation.ts | 87 ----- .../src/main/js/language/problem.grammar | 149 --------- .../src/main/js/language/problemLanguageSupport.ts | 92 ------ .../language-web/src/main/js/language/props.ts | 7 - .../language-web/src/main/js/theme/EditorTheme.ts | 47 --- .../src/main/js/theme/ThemeProvider.tsx | 15 - .../language-web/src/main/js/theme/ThemeStore.ts | 64 ---- .../src/main/js/utils/ConditionVariable.ts | 64 ---- .../language-web/src/main/js/utils/PendingTask.ts | 60 ---- .../language-web/src/main/js/utils/Timer.ts | 33 -- .../language-web/src/main/js/utils/logger.ts | 49 --- .../src/main/js/xtext/ContentAssistService.ts | 219 ------------- .../src/main/js/xtext/HighlightingService.ts | 37 --- .../src/main/js/xtext/OccurrencesService.ts | 127 ------- .../src/main/js/xtext/UpdateService.ts | 363 --------------------- .../src/main/js/xtext/ValidationService.ts | 39 --- .../language-web/src/main/js/xtext/XtextClient.ts | 86 ----- .../src/main/js/xtext/XtextWebSocketClient.ts | 362 -------------------- .../src/main/js/xtext/xtextMessages.ts | 40 --- .../src/main/js/xtext/xtextServiceResults.ts | 112 ------- subprojects/language-web/tsconfig.json | 18 - subprojects/language-web/tsconfig.sonar.json | 17 - subprojects/language-web/webpack.config.js | 232 ------------- yarn.lock | 50 ++- 92 files changed, 3805 insertions(+), 3870 deletions(-) create mode 100644 subprojects/frontend/.eslintrc.js create mode 100644 subprojects/frontend/.stylelintrc.js create mode 100644 subprojects/frontend/build.gradle create mode 100644 subprojects/frontend/package.json create mode 100644 subprojects/frontend/src/App.tsx create mode 100644 subprojects/frontend/src/RootStore.tsx create mode 100644 subprojects/frontend/src/editor/EditorArea.tsx create mode 100644 subprojects/frontend/src/editor/EditorButtons.tsx create mode 100644 subprojects/frontend/src/editor/EditorParent.ts create mode 100644 subprojects/frontend/src/editor/EditorStore.ts create mode 100644 subprojects/frontend/src/editor/GenerateButton.tsx create mode 100644 subprojects/frontend/src/editor/decorationSetExtension.ts create mode 100644 subprojects/frontend/src/editor/findOccurrences.ts create mode 100644 subprojects/frontend/src/editor/semanticHighlighting.ts create mode 100644 subprojects/frontend/src/global.d.ts create mode 100644 subprojects/frontend/src/index.html create mode 100644 subprojects/frontend/src/index.scss create mode 100644 subprojects/frontend/src/index.tsx create mode 100644 subprojects/frontend/src/language/folding.ts create mode 100644 subprojects/frontend/src/language/indentation.ts create mode 100644 subprojects/frontend/src/language/problem.grammar create mode 100644 subprojects/frontend/src/language/problemLanguageSupport.ts create mode 100644 subprojects/frontend/src/language/props.ts create mode 100644 subprojects/frontend/src/theme/EditorTheme.ts create mode 100644 subprojects/frontend/src/theme/ThemeProvider.tsx create mode 100644 subprojects/frontend/src/theme/ThemeStore.ts create mode 100644 subprojects/frontend/src/themeVariables.module.scss create mode 100644 subprojects/frontend/src/themes.scss create mode 100644 subprojects/frontend/src/utils/ConditionVariable.ts create mode 100644 subprojects/frontend/src/utils/PendingTask.ts create mode 100644 subprojects/frontend/src/utils/Timer.ts create mode 100644 subprojects/frontend/src/utils/logger.ts create mode 100644 subprojects/frontend/src/xtext/ContentAssistService.ts create mode 100644 subprojects/frontend/src/xtext/HighlightingService.ts create mode 100644 subprojects/frontend/src/xtext/OccurrencesService.ts create mode 100644 subprojects/frontend/src/xtext/UpdateService.ts create mode 100644 subprojects/frontend/src/xtext/ValidationService.ts create mode 100644 subprojects/frontend/src/xtext/XtextClient.ts create mode 100644 subprojects/frontend/src/xtext/XtextWebSocketClient.ts create mode 100644 subprojects/frontend/src/xtext/xtextMessages.ts create mode 100644 subprojects/frontend/src/xtext/xtextServiceResults.ts create mode 100644 subprojects/frontend/tsconfig.json create mode 100644 subprojects/frontend/tsconfig.sonar.json create mode 100644 subprojects/frontend/webpack.config.js delete mode 100644 subprojects/language-web/.editorconfig delete mode 100644 subprojects/language-web/.eslintrc.js delete mode 100644 subprojects/language-web/.stylelintrc.js delete mode 100644 subprojects/language-web/package.json delete mode 100644 subprojects/language-web/src/main/css/index.scss delete mode 100644 subprojects/language-web/src/main/css/themeVariables.module.scss delete mode 100644 subprojects/language-web/src/main/css/themes.scss delete mode 100644 subprojects/language-web/src/main/html/index.html delete mode 100644 subprojects/language-web/src/main/js/App.tsx delete mode 100644 subprojects/language-web/src/main/js/RootStore.tsx delete mode 100644 subprojects/language-web/src/main/js/editor/EditorArea.tsx delete mode 100644 subprojects/language-web/src/main/js/editor/EditorButtons.tsx delete mode 100644 subprojects/language-web/src/main/js/editor/EditorParent.ts delete mode 100644 subprojects/language-web/src/main/js/editor/EditorStore.ts delete mode 100644 subprojects/language-web/src/main/js/editor/GenerateButton.tsx delete mode 100644 subprojects/language-web/src/main/js/editor/decorationSetExtension.ts delete mode 100644 subprojects/language-web/src/main/js/editor/findOccurrences.ts delete mode 100644 subprojects/language-web/src/main/js/editor/semanticHighlighting.ts delete mode 100644 subprojects/language-web/src/main/js/global.d.ts delete mode 100644 subprojects/language-web/src/main/js/index.tsx delete mode 100644 subprojects/language-web/src/main/js/language/folding.ts delete mode 100644 subprojects/language-web/src/main/js/language/indentation.ts delete mode 100644 subprojects/language-web/src/main/js/language/problem.grammar delete mode 100644 subprojects/language-web/src/main/js/language/problemLanguageSupport.ts delete mode 100644 subprojects/language-web/src/main/js/language/props.ts delete mode 100644 subprojects/language-web/src/main/js/theme/EditorTheme.ts delete mode 100644 subprojects/language-web/src/main/js/theme/ThemeProvider.tsx delete mode 100644 subprojects/language-web/src/main/js/theme/ThemeStore.ts delete mode 100644 subprojects/language-web/src/main/js/utils/ConditionVariable.ts delete mode 100644 subprojects/language-web/src/main/js/utils/PendingTask.ts delete mode 100644 subprojects/language-web/src/main/js/utils/Timer.ts delete mode 100644 subprojects/language-web/src/main/js/utils/logger.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/ContentAssistService.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/HighlightingService.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/OccurrencesService.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/UpdateService.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/ValidationService.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/XtextClient.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/xtextMessages.ts delete mode 100644 subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts delete mode 100644 subprojects/language-web/tsconfig.json delete mode 100644 subprojects/language-web/tsconfig.sonar.json delete mode 100644 subprojects/language-web/webpack.config.js diff --git a/package.json b/package.json index b06fc782..1df63f5d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "workspaces": [ - "subprojects/language-web" + "subprojects/frontend" ], "devDependencies": { "eslint": "^8.4.1", diff --git a/settings.gradle b/settings.gradle index b1e298ca..626ae401 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ rootProject.name = 'refinery' +include 'frontend' include 'language' include 'language-ide' include 'language-model' diff --git a/subprojects/frontend/.eslintrc.js b/subprojects/frontend/.eslintrc.js new file mode 100644 index 00000000..aa7636f8 --- /dev/null +++ b/subprojects/frontend/.eslintrc.js @@ -0,0 +1,39 @@ +// Loosely based on +// https://github.com/iamturns/create-exposed-app/blob/f14e435b8ce179c89cce3eea89e56202153a53da/.eslintrc.js +module.exports = { + plugins: [ + '@typescript-eslint', + ], + extends: [ + 'airbnb', + 'airbnb-typescript', + 'airbnb/hooks', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + ], + parserOptions: { + project: './tsconfig.json', + }, + rules: { + // https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html + 'import/prefer-default-export': 'off', + 'import/no-default-export': 'error', + // propTypes are for runtime validation, but we rely on TypeScript for build-time validation: + // https://github.com/yannickcr/eslint-plugin-react/issues/2275#issuecomment-492003857 + 'react/prop-types': 'off', + // Make sure switches are exhaustive: https://stackoverflow.com/a/60166264 + 'default-case': 'off', + '@typescript-eslint/switch-exhaustiveness-check': 'error', + // https://github.com/airbnb/javascript/pull/2501 + 'react/function-component-definition': ['error', { + namedComponents: 'function-declaration', + }], + }, + env: { + browser: true, + }, + ignorePatterns: [ + '*.js', + 'build/**/*', + ], +}; diff --git a/subprojects/frontend/.stylelintrc.js b/subprojects/frontend/.stylelintrc.js new file mode 100644 index 00000000..7adf8f26 --- /dev/null +++ b/subprojects/frontend/.stylelintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: 'stylelint-config-recommended-scss', + // Simplified for only :export to TypeScript based on + // https://github.com/pascalduez/stylelint-config-css-modules/blob/d792a6ac7d2bce8239edccbc5a72e0616f22d696/index.js + rules: { + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: [ + 'export', + ], + }, + ], + }, +}; diff --git a/subprojects/frontend/build.gradle b/subprojects/frontend/build.gradle new file mode 100644 index 00000000..71444e89 --- /dev/null +++ b/subprojects/frontend/build.gradle @@ -0,0 +1,101 @@ +plugins { + id 'refinery-frontend-workspace' + id 'refinery-sonarqube' +} + +import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn + +def webpackOutputDir = "${buildDir}/webpack" +def productionResources = file("${webpackOutputDir}/production") + +frontend { + assembleScript = 'assemble:webpack' +} + +configurations { + productionAssets { + canBeConsumed = true + canBeResolved = false + } +} + +def installFrontend = tasks.named('installFrontend') + +def generateLezerGrammar = tasks.register('generateLezerGrammar', RunYarn) { + dependsOn installFrontend + inputs.file 'src/language/problem.grammar' + inputs.file 'package.json' + inputs.file rootProject.file('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 generateLezerGrammar + inputs.dir 'src' + inputs.file "${buildDir}/generated/sources/lezer/problem.ts" + inputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts" + inputs.files('package.json', 'webpack.config.js') + inputs.file rootProject.file('yarn.lock') + outputs.dir productionResources +} + +artifacts { + productionAssets(productionResources) { + builtBy assembleFrontend + } +} + +def eslint = tasks.register('eslint', RunYarn) { + dependsOn installFrontend + inputs.dir 'src' + inputs.files('.eslintrc.js', 'tsconfig.json') + inputs.file rootProject.file('yarn.lock') + if (project.hasProperty('ci')) { + outputs.file "${buildDir}/eslint.json" + script = 'run check:eslint:ci' + } else { + script = 'run check:eslint' + } + group = 'verification' + description = 'Check for TypeScript errors.' +} + +def stylelint = tasks.register('stylelint', RunYarn) { + dependsOn installFrontend + inputs.dir 'src' + inputs.file '.stylelintrc.js' + inputs.file rootProject.file('yarn.lock') + if (project.hasProperty('ci')) { + outputs.file "${buildDir}/stylelint.json" + script = 'run check:stylelint:ci' + } else { + script = 'run check:stylelint' + } + group = 'verification' + description = 'Check for Sass errors.' +} + +tasks.named('check') { + dependsOn(eslint, stylelint) +} + +tasks.register('webpackServe', RunYarn) { + dependsOn installFrontend + dependsOn generateLezerGrammar + outputs.dir "${webpackOutputDir}/development" + script = 'run serve' + group = 'run' + description = 'Start a Webpack dev server with hot module replacement.' +} + +sonarqube.properties { + properties['sonar.sources'] += ['src'] + property 'sonar.nodejs.executable', "${frontend.nodeInstallDirectory.get()}/bin/node" + property 'sonar.eslint.reportPaths', "${buildDir}/eslint.json" + property 'sonar.css.stylelint.reportPaths', "${buildDir}/stylelint.json" + // SonarJS does not pick up typescript files with `exactOptionalPropertyTypes` + property 'sonar.typescript.tsconfigPath', 'tsconfig.sonar.json' +} diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json new file mode 100644 index 00000000..14795885 --- /dev/null +++ b/subprojects/frontend/package.json @@ -0,0 +1,102 @@ +{ + "name": "@refinery/frontend", + "version": "0.0.0", + "description": "Web frontend for Refinery", + "main": "index.js", + "scripts": { + "assemble:lezer": "lezer-generator src/language/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 .", + "check:eslint:ci": "eslint -f json -o build/eslint.json .", + "check:stylelint": "stylelint src/**/*.scss", + "check:stylelint:ci": "stylelint -f json src/**/*.scss > build/stylelint.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/graphs4value/refinery.git" + }, + "author": "Refinery authors", + "license": "EPL-2.0", + "bugs": { + "url": "https://github.com/graphs4value/issues" + }, + "homepage": "https://refinery.tools", + "devDependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@lezer/generator": "^0.15.2", + "@principalstudio/html-webpack-inject-preload": "^1.2.7", + "@types/react": "^17.0.37", + "@types/react-dom": "^17.0.11", + "@typescript-eslint/eslint-plugin": "^5.6.0", + "@typescript-eslint/parser": "^5.6.0", + "babel-loader": "^8.2.3", + "css-loader": "^6.5.1", + "eslint": "^8.4.1", + "eslint-config-airbnb": "^19.0.2", + "eslint-config-airbnb-typescript": "^16.1.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "html-webpack-plugin": "^5.5.0", + "image-webpack-loader": "^8.0.1", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.5", + "postcss-scss": "^4.0.2", + "sass": "^1.45.0", + "sass-loader": "^12.4.0", + "style-loader": "^3.3.1", + "stylelint": "^14.1.0", + "stylelint-config-recommended-scss": "^5.0.2", + "stylelint-scss": "^4.0.1", + "typescript": "~4.5.3", + "webpack": "^5.65.0", + "webpack-cli": "^4.9.1", + "webpack-dev-server": "^4.6.0", + "webpack-subresource-integrity": "^5.0.0" + }, + "dependencies": { + "@babel/runtime": "^7.16.3", + "@codemirror/autocomplete": "^0.19.9", + "@codemirror/closebrackets": "^0.19.0", + "@codemirror/commands": "^0.19.6", + "@codemirror/comment": "^0.19.0", + "@codemirror/fold": "^0.19.2", + "@codemirror/gutter": "^0.19.9", + "@codemirror/highlight": "^0.19.6", + "@codemirror/history": "^0.19.0", + "@codemirror/language": "^0.19.7", + "@codemirror/lint": "^0.19.3", + "@codemirror/matchbrackets": "^0.19.3", + "@codemirror/rangeset": "^0.19.2", + "@codemirror/rectangular-selection": "^0.19.1", + "@codemirror/search": "^0.19.4", + "@codemirror/state": "^0.19.6", + "@codemirror/view": "^0.19.29", + "@emotion/react": "^11.7.1", + "@emotion/styled": "^11.6.0", + "@fontsource/jetbrains-mono": "^4.5.0", + "@fontsource/roboto": "^4.5.1", + "@lezer/common": "^0.15.10", + "@lezer/lr": "^0.15.5", + "@mui/icons-material": "5.2.1", + "@mui/material": "5.2.3", + "ansi-styles": "^6.1.0", + "escape-string-regexp": "^5.0.0", + "loglevel": "^1.8.0", + "loglevel-plugin-prefix": "^0.8.4", + "mobx": "^6.3.8", + "mobx-react-lite": "^3.2.2", + "nanoid": "^3.1.30", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "zod": "^3.11.6" + } +} diff --git a/subprojects/frontend/src/App.tsx b/subprojects/frontend/src/App.tsx new file mode 100644 index 00000000..54f92f9a --- /dev/null +++ b/subprojects/frontend/src/App.tsx @@ -0,0 +1,60 @@ +import AppBar from '@mui/material/AppBar'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import MenuIcon from '@mui/icons-material/Menu'; +import React from 'react'; + +import { EditorArea } from './editor/EditorArea'; +import { EditorButtons } from './editor/EditorButtons'; +import { GenerateButton } from './editor/GenerateButton'; + +export function App(): JSX.Element { + return ( + + + + + + + + Refinery + + + + + + + + + + + + ); +} diff --git a/subprojects/frontend/src/RootStore.tsx b/subprojects/frontend/src/RootStore.tsx new file mode 100644 index 00000000..baf0b61e --- /dev/null +++ b/subprojects/frontend/src/RootStore.tsx @@ -0,0 +1,39 @@ +import React, { createContext, useContext } from 'react'; + +import { EditorStore } from './editor/EditorStore'; +import { ThemeStore } from './theme/ThemeStore'; + +export class RootStore { + editorStore; + + themeStore; + + constructor(initialValue: string) { + this.themeStore = new ThemeStore(); + this.editorStore = new EditorStore(initialValue, this.themeStore); + } +} + +const StoreContext = createContext(undefined); + +export interface RootStoreProviderProps { + children: JSX.Element; + + rootStore: RootStore; +} + +export function RootStoreProvider({ children, rootStore }: RootStoreProviderProps): JSX.Element { + return ( + + {children} + + ); +} + +export const useRootStore = (): RootStore => { + const rootStore = useContext(StoreContext); + if (!rootStore) { + throw new Error('useRootStore must be used within RootStoreProvider'); + } + return rootStore; +}; diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx new file mode 100644 index 00000000..dba20f6e --- /dev/null +++ b/subprojects/frontend/src/editor/EditorArea.tsx @@ -0,0 +1,152 @@ +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, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { EditorParent } from './EditorParent'; +import { useRootStore } from '../RootStore'; +import { getLogger } from '../utils/logger'; + +const log = getLogger('editor.EditorArea'); + +function usePanel( + 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; + } + 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, + panelId, + openCommand, + closeCommand, + closeCallback, + ]); + return setCachedViewState; +} + +function fixCodeMirrorAccessibility(editorView: EditorView) { + // Reported by Lighthouse 8.3.0. + const { contentDOM } = editorView; + contentDOM.removeAttribute('aria-expanded'); + contentDOM.setAttribute('aria-label', 'Code editor'); +} + +export const EditorArea = observer(() => { + const { editorStore } = useRootStore(); + const editorParentRef = useRef(null); + const [editorViewState, setEditorViewState] = useState(null); + + const setSearchPanelOpen = usePanel( + 'search', + editorStore.showSearchPanel, + editorViewState, + openSearchPanel, + closeSearchPanel, + useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), + ); + + const setLintPanelOpen = usePanel( + 'panel-lint', + editorStore.showLintPanel, + editorViewState, + openLintPanel, + closeLintPanel, + useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), + ); + + useEffect(() => { + if (editorParentRef.current === null) { + return () => { + // Nothing to clean up. + }; + } + + const editorView = new EditorView({ + state: editorStore.state, + parent: editorParentRef.current, + dispatch: (transaction) => { + editorStore.onTransaction(transaction); + editorView.update([transaction]); + if (editorView.state !== editorStore.state) { + log.error( + 'Failed to synchronize editor state - store state:', + editorStore.state, + 'view state:', + editorView.state, + ); + } + }, + }); + fixCodeMirrorAccessibility(editorView); + setEditorViewState(editorView); + setSearchPanelOpen(false); + setLintPanelOpen(false); + // `dispatch` is bound to the view instance, + // so it does not have to be called as a method. + // eslint-disable-next-line @typescript-eslint/unbound-method + editorStore.updateDispatcher(editorView.dispatch); + log.info('Editor created'); + + return () => { + editorStore.updateDispatcher(null); + editorView.destroy(); + log.info('Editor destroyed'); + }; + }, [ + editorParentRef, + editorStore, + setSearchPanelOpen, + setLintPanelOpen, + ]); + + return ( + + ); +}); diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx new file mode 100644 index 00000000..150aa00d --- /dev/null +++ b/subprojects/frontend/src/editor/EditorButtons.tsx @@ -0,0 +1,98 @@ +import type { Diagnostic } from '@codemirror/lint'; +import { observer } from 'mobx-react-lite'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import CheckIcon from '@mui/icons-material/Check'; +import ErrorIcon from '@mui/icons-material/Error'; +import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; +import FormatPaint from '@mui/icons-material/FormatPaint'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import RedoIcon from '@mui/icons-material/Redo'; +import SearchIcon from '@mui/icons-material/Search'; +import UndoIcon from '@mui/icons-material/Undo'; +import WarningIcon from '@mui/icons-material/Warning'; +import React from 'react'; + +import { useRootStore } from '../RootStore'; + +// Exhastive switch as proven by TypeScript. +// eslint-disable-next-line consistent-return +function getLintIcon(severity: Diagnostic['severity'] | null) { + switch (severity) { + case 'error': + return ; + case 'warning': + return ; + case 'info': + return ; + case null: + return ; + } +} + +export const EditorButtons = observer(() => { + const { editorStore } = useRootStore(); + + return ( + + + editorStore.undo()} + aria-label="Undo" + > + + + editorStore.redo()} + aria-label="Redo" + > + + + + + editorStore.toggleLineNumbers()} + aria-label="Show line numbers" + value="show-line-numbers" + > + + + editorStore.toggleSearchPanel()} + aria-label="Show find/replace" + value="show-search-panel" + > + + + editorStore.toggleLintPanel()} + aria-label="Show diagnostics panel" + value="show-lint-panel" + > + {getLintIcon(editorStore.highestDiagnosticLevel)} + + + editorStore.formatText()} + aria-label="Automatic format" + > + + + + ); +}); diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts new file mode 100644 index 00000000..94ca24ea --- /dev/null +++ b/subprojects/frontend/src/editor/EditorParent.ts @@ -0,0 +1,205 @@ +import { styled } from '@mui/material/styles'; + +/** + * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64. + * + * Based on + * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501 + * + * @param color the color of the underline + * @returns the CSS `url()` + */ +function underline(color: string) { + const svg = ` + + `; + const svgBase64 = window.btoa(svg); + return `url('data:image/svg+xml;base64,${svgBase64}')`; +} + +export const EditorParent = styled('div')(({ theme }) => { + const codeMirrorLintStyle: Record = {}; + (['error', 'warning', 'info'] as const).forEach((severity) => { + const color = theme.palette[severity].main; + codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = { + borderLeftColor: color, + }; + codeMirrorLintStyle[`.cm-lintRange-${severity}`] = { + backgroundImage: underline(color), + }; + }); + + return { + background: theme.palette.background.default, + '&, .cm-editor': { + height: '100%', + }, + '.cm-content': { + padding: 0, + }, + '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { + fontSize: 16, + fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', + fontFeatureSettings: '"liga", "calt"', + fontWeight: 400, + letterSpacing: 0, + textRendering: 'optimizeLegibility', + }, + '.cm-scroller': { + color: theme.palette.text.secondary, + }, + '.cm-gutters': { + background: 'rgba(255, 255, 255, 0.1)', + color: theme.palette.text.disabled, + border: 'none', + }, + '.cm-specialChar': { + color: theme.palette.secondary.main, + }, + '.cm-activeLine': { + background: 'rgba(0, 0, 0, 0.3)', + }, + '.cm-activeLineGutter': { + background: 'transparent', + }, + '.cm-lineNumbers .cm-activeLineGutter': { + color: theme.palette.text.primary, + }, + '.cm-cursor, .cm-cursor-primary': { + borderColor: theme.palette.primary.main, + background: theme.palette.common.black, + }, + '.cm-selectionBackground': { + background: '#3e4453', + }, + '.cm-focused': { + outline: 'none', + '.cm-selectionBackground': { + background: '#3e4453', + }, + }, + '.cm-panels-top': { + color: theme.palette.text.secondary, + }, + '.cm-panel': { + '&, & button, & input': { + fontFamily: '"Roboto","Helvetica","Arial",sans-serif', + }, + background: theme.palette.background.paper, + borderTop: `1px solid ${theme.palette.divider}`, + 'button[name="close"]': { + background: 'transparent', + color: theme.palette.text.secondary, + cursor: 'pointer', + }, + }, + '.cm-panel.cm-panel-lint': { + 'button[name="close"]': { + // Close button interferes with scrollbar, so we better hide it. + // The panel can still be closed from the toolbar. + display: 'none', + }, + ul: { + li: { + borderBottom: `1px solid ${theme.palette.divider}`, + cursor: 'pointer', + }, + '[aria-selected]': { + background: '#3e4453', + color: theme.palette.text.primary, + }, + '&:focus [aria-selected]': { + background: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + }, + '.cm-foldPlaceholder': { + background: theme.palette.background.paper, + borderColor: theme.palette.text.disabled, + color: theme.palette.text.secondary, + }, + '.cmt-comment': { + fontStyle: 'italic', + color: theme.palette.text.disabled, + }, + '.cmt-number': { + color: '#6188a6', + }, + '.cmt-string': { + color: theme.palette.secondary.dark, + }, + '.cmt-keyword': { + color: theme.palette.primary.main, + }, + '.cmt-typeName, .cmt-macroName, .cmt-atom': { + color: theme.palette.text.primary, + }, + '.cmt-variableName': { + color: '#c8ae9d', + }, + '.cmt-problem-node': { + '&, & .cmt-variableName': { + color: theme.palette.text.secondary, + }, + }, + '.cmt-problem-individual': { + '&, & .cmt-variableName': { + color: theme.palette.text.primary, + }, + }, + '.cmt-problem-abstract, .cmt-problem-new': { + fontStyle: 'italic', + }, + '.cmt-problem-containment': { + fontWeight: 700, + }, + '.cmt-problem-error': { + '&, & .cmt-typeName': { + color: theme.palette.error.main, + }, + }, + '.cmt-problem-builtin': { + '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': { + color: theme.palette.primary.main, + fontWeight: 400, + fontStyle: 'normal', + }, + }, + '.cm-tooltip-autocomplete': { + background: theme.palette.background.paper, + boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%), + 0px 4px 5px 0px rgb(0 0 0 / 14%), + 0px 1px 10px 0px rgb(0 0 0 / 12%)`, + '.cm-completionIcon': { + color: theme.palette.text.secondary, + }, + '.cm-completionLabel': { + color: theme.palette.text.primary, + }, + '.cm-completionDetail': { + color: theme.palette.text.secondary, + fontStyle: 'normal', + }, + '[aria-selected]': { + background: `${theme.palette.primary.main} !important`, + '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { + color: theme.palette.primary.contrastText, + }, + }, + }, + '.cm-completionIcon': { + width: 16, + padding: 0, + marginRight: '0.5em', + textAlign: 'center', + }, + ...codeMirrorLintStyle, + '.cm-problem-write': { + background: 'rgba(255, 255, 128, 0.3)', + }, + '.cm-problem-read': { + background: 'rgba(255, 255, 255, 0.15)', + }, + }; +}); diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts new file mode 100644 index 00000000..5760de28 --- /dev/null +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -0,0 +1,289 @@ +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { commentKeymap } from '@codemirror/comment'; +import { foldGutter, foldKeymap } from '@codemirror/fold'; +import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; +import { classHighlightStyle } from '@codemirror/highlight'; +import { + history, + historyKeymap, + redo, + redoDepth, + undo, + undoDepth, +} from '@codemirror/history'; +import { indentOnInput } from '@codemirror/language'; +import { + Diagnostic, + lintKeymap, + setDiagnostics, +} from '@codemirror/lint'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { rectangularSelection } from '@codemirror/rectangular-selection'; +import { searchConfig, searchKeymap } from '@codemirror/search'; +import { + EditorState, + StateCommand, + StateEffect, + Transaction, + TransactionSpec, +} from '@codemirror/state'; +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightSpecialChars, + keymap, +} from '@codemirror/view'; +import { + makeAutoObservable, + observable, + reaction, +} from 'mobx'; + +import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; +import { problemLanguageSupport } from '../language/problemLanguageSupport'; +import { + IHighlightRange, + semanticHighlighting, + setSemanticHighlighting, +} from './semanticHighlighting'; +import type { ThemeStore } from '../theme/ThemeStore'; +import { getLogger } from '../utils/logger'; +import { XtextClient } from '../xtext/XtextClient'; + +const log = getLogger('editor.EditorStore'); + +export class EditorStore { + private readonly themeStore; + + state: EditorState; + + private readonly client: XtextClient; + + showLineNumbers = false; + + showSearchPanel = false; + + showLintPanel = false; + + errorCount = 0; + + warningCount = 0; + + infoCount = 0; + + private readonly defaultDispatcher = (tr: Transaction): void => { + this.onTransaction(tr); + }; + + private dispatcher = this.defaultDispatcher; + + constructor(initialValue: string, themeStore: ThemeStore) { + this.themeStore = themeStore; + this.state = EditorState.create({ + doc: initialValue, + extensions: [ + autocompletion({ + activateOnTyping: true, + override: [ + (context) => this.client.contentAssist(context), + ], + }), + classHighlightStyle.extension, + closeBrackets(), + bracketMatching(), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + EditorView.theme({}, { + dark: this.themeStore.darkMode, + }), + findOccurrences, + highlightActiveLine(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + indentOnInput(), + rectangularSelection(), + searchConfig({ + top: true, + matchCase: true, + }), + semanticHighlighting, + // We add the gutters to `extensions` in the order we want them to appear. + lineNumbers(), + foldGutter(), + keymap.of([ + { key: 'Mod-Shift-f', run: () => this.formatText() }, + ...closeBracketsKeymap, + ...commentKeymap, + ...completionKeymap, + ...foldKeymap, + ...historyKeymap, + indentWithTab, + // Override keys in `lintKeymap` to go through the `EditorStore`. + { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) }, + ...lintKeymap, + // Override keys in `searchKeymap` to go through the `EditorStore`. + { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' }, + { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' }, + ...searchKeymap, + ...defaultKeymap, + ]), + problemLanguageSupport(), + ], + }); + this.client = new XtextClient(this); + reaction( + () => this.themeStore.darkMode, + (darkMode) => { + log.debug('Update editor dark mode', darkMode); + this.dispatch({ + effects: [ + StateEffect.appendConfig.of(EditorView.theme({}, { + dark: darkMode, + })), + ], + }); + }, + ); + makeAutoObservable(this, { + state: observable.ref, + }); + } + + updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { + this.dispatcher = newDispatcher || this.defaultDispatcher; + } + + onTransaction(tr: Transaction): void { + log.trace('Editor transaction', tr); + this.state = tr.state; + this.client.onTransaction(tr); + } + + dispatch(...specs: readonly TransactionSpec[]): void { + this.dispatcher(this.state.update(...specs)); + } + + doStateCommand(command: StateCommand): boolean { + return command({ + state: this.state, + dispatch: this.dispatcher, + }); + } + + updateDiagnostics(diagnostics: Diagnostic[]): void { + this.dispatch(setDiagnostics(this.state, diagnostics)); + this.errorCount = 0; + this.warningCount = 0; + this.infoCount = 0; + diagnostics.forEach(({ severity }) => { + switch (severity) { + case 'error': + this.errorCount += 1; + break; + case 'warning': + this.warningCount += 1; + break; + case 'info': + this.infoCount += 1; + break; + } + }); + } + + get highestDiagnosticLevel(): Diagnostic['severity'] | null { + if (this.errorCount > 0) { + return 'error'; + } + if (this.warningCount > 0) { + return 'warning'; + } + if (this.infoCount > 0) { + return 'info'; + } + return null; + } + + updateSemanticHighlighting(ranges: IHighlightRange[]): void { + this.dispatch(setSemanticHighlighting(ranges)); + } + + updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void { + this.dispatch(setOccurrences(write, read)); + } + + /** + * @returns `true` if there is history to undo + */ + get canUndo(): boolean { + return undoDepth(this.state) > 0; + } + + // eslint-disable-next-line class-methods-use-this + undo(): void { + log.debug('Undo', this.doStateCommand(undo)); + } + + /** + * @returns `true` if there is history to redo + */ + get canRedo(): boolean { + return redoDepth(this.state) > 0; + } + + // eslint-disable-next-line class-methods-use-this + redo(): void { + log.debug('Redo', this.doStateCommand(redo)); + } + + toggleLineNumbers(): void { + this.showLineNumbers = !this.showLineNumbers; + log.debug('Show line numbers', this.showLineNumbers); + } + + /** + * Sets whether the CodeMirror search panel should be open. + * + * This method can be used as a CodeMirror command, + * because it returns `false` if it didn't execute, + * allowing other commands for the same keybind to run instead. + * This matches the behavior of the `openSearchPanel` and `closeSearchPanel` + * commands from `'@codemirror/search'`. + * + * @param newShosSearchPanel whether we should show the search panel + * @returns `true` if the state was changed, `false` otherwise + */ + setSearchPanelOpen(newShowSearchPanel: boolean): boolean { + if (this.showSearchPanel === newShowSearchPanel) { + return false; + } + this.showSearchPanel = newShowSearchPanel; + log.debug('Show search panel', this.showSearchPanel); + return true; + } + + toggleSearchPanel(): void { + this.setSearchPanelOpen(!this.showSearchPanel); + } + + setLintPanelOpen(newShowLintPanel: boolean): boolean { + if (this.showLintPanel === newShowLintPanel) { + return false; + } + this.showLintPanel = newShowLintPanel; + log.debug('Show lint panel', this.showLintPanel); + return true; + } + + toggleLintPanel(): void { + this.setLintPanelOpen(!this.showLintPanel); + } + + formatText(): boolean { + this.client.formatText(); + return true; + } +} diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx new file mode 100644 index 00000000..3834cec4 --- /dev/null +++ b/subprojects/frontend/src/editor/GenerateButton.tsx @@ -0,0 +1,44 @@ +import { observer } from 'mobx-react-lite'; +import Button from '@mui/material/Button'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import React from 'react'; + +import { useRootStore } from '../RootStore'; + +const GENERATE_LABEL = 'Generate'; + +export const GenerateButton = observer(() => { + const { editorStore } = useRootStore(); + const { errorCount, warningCount } = editorStore; + + const diagnostics: string[] = []; + if (errorCount > 0) { + diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`); + } + if (warningCount > 0) { + diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`); + } + const summary = diagnostics.join(' and '); + + if (errorCount > 0) { + return ( + + ); + } + + return ( + + ); +}); diff --git a/subprojects/frontend/src/editor/decorationSetExtension.ts b/subprojects/frontend/src/editor/decorationSetExtension.ts new file mode 100644 index 00000000..2d630c20 --- /dev/null +++ b/subprojects/frontend/src/editor/decorationSetExtension.ts @@ -0,0 +1,39 @@ +import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; +import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; + +export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; + +export function decorationSetExtension(): [TransactionSpecFactory, StateField] { + const setEffect = StateEffect.define(); + const field = StateField.define({ + create() { + return Decoration.none; + }, + update(currentDecorations, transaction) { + let newDecorations: DecorationSet | null = null; + transaction.effects.forEach((effect) => { + if (effect.is(setEffect)) { + newDecorations = effect.value; + } + }); + if (newDecorations === null) { + if (transaction.docChanged) { + return currentDecorations.map(transaction.changes); + } + return currentDecorations; + } + return newDecorations; + }, + provide: (f) => EditorView.decorations.from(f), + }); + + function transactionSpecFactory(decorations: DecorationSet) { + return { + effects: [ + setEffect.of(decorations), + ], + }; + } + + return [transactionSpecFactory, field]; +} diff --git a/subprojects/frontend/src/editor/findOccurrences.ts b/subprojects/frontend/src/editor/findOccurrences.ts new file mode 100644 index 00000000..92102746 --- /dev/null +++ b/subprojects/frontend/src/editor/findOccurrences.ts @@ -0,0 +1,35 @@ +import { Range, RangeSet } from '@codemirror/rangeset'; +import type { TransactionSpec } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; + +import { decorationSetExtension } from './decorationSetExtension'; + +export interface IOccurrence { + from: number; + + to: number; +} + +const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); + +const writeDecoration = Decoration.mark({ + class: 'cm-problem-write', +}); + +const readDecoration = Decoration.mark({ + class: 'cm-problem-read', +}); + +export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec { + const decorations: Range[] = []; + write.forEach(({ from, to }) => { + decorations.push(writeDecoration.range(from, to)); + }); + read.forEach(({ from, to }) => { + decorations.push(readDecoration.range(from, to)); + }); + const rangeSet = RangeSet.of(decorations, true); + return setOccurrencesInteral(rangeSet); +} + +export { findOccurrences }; diff --git a/subprojects/frontend/src/editor/semanticHighlighting.ts b/subprojects/frontend/src/editor/semanticHighlighting.ts new file mode 100644 index 00000000..2aed421b --- /dev/null +++ b/subprojects/frontend/src/editor/semanticHighlighting.ts @@ -0,0 +1,24 @@ +import { RangeSet } from '@codemirror/rangeset'; +import type { TransactionSpec } from '@codemirror/state'; +import { Decoration } from '@codemirror/view'; + +import { decorationSetExtension } from './decorationSetExtension'; + +export interface IHighlightRange { + from: number; + + to: number; + + classes: string[]; +} + +const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); + +export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec { + const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({ + class: classes.map((c) => `cmt-problem-${c}`).join(' '), + }).range(from, to)), true); + return setSemanticHighlightingInternal(rangeSet); +} + +export { semanticHighlighting }; diff --git a/subprojects/frontend/src/global.d.ts b/subprojects/frontend/src/global.d.ts new file mode 100644 index 00000000..0533a46e --- /dev/null +++ b/subprojects/frontend/src/global.d.ts @@ -0,0 +1,11 @@ +declare const DEBUG: boolean; + +declare const PACKAGE_NAME: string; + +declare const PACKAGE_VERSION: string; + +declare module '*.module.scss' { + const cssVariables: { [key in string]?: string }; + // eslint-disable-next-line import/no-default-export + export default cssVariables; +} diff --git a/subprojects/frontend/src/index.html b/subprojects/frontend/src/index.html new file mode 100644 index 00000000..f404aa8a --- /dev/null +++ b/subprojects/frontend/src/index.html @@ -0,0 +1,16 @@ + + + + + + Refinery + + + +
+ + diff --git a/subprojects/frontend/src/index.scss b/subprojects/frontend/src/index.scss new file mode 100644 index 00000000..ad876aaf --- /dev/null +++ b/subprojects/frontend/src/index.scss @@ -0,0 +1,16 @@ +@use '@fontsource/roboto/scss/mixins' as Roboto; +@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono; + +$fontWeights: 300, 400, 500, 700; +@each $weight in $fontWeights { + @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight); + @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight, $style: italic); +} + +$monoFontWeights: 400, 700; +@each $weight in $monoFontWeights { + @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight); + @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight, $style: italic); +} +@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable'); +@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic); diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx new file mode 100644 index 00000000..15b26adb --- /dev/null +++ b/subprojects/frontend/src/index.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render } from 'react-dom'; +import CssBaseline from '@mui/material/CssBaseline'; + +import { App } from './App'; +import { RootStore, RootStoreProvider } from './RootStore'; +import { ThemeProvider } from './theme/ThemeProvider'; + +import './index.scss'; + +const initialValue = `class Family { + contains Person[] members +} + +class Person { + Person[] children opposite parent + Person[0..1] parent opposite children + int age + TaxStatus taxStatus +} + +enum TaxStatus { + child, student, adult, retired +} + +% A child cannot have any dependents. +pred invalidTaxStatus(Person p) <-> + taxStatus(p, child), + children(p, _q) + ; taxStatus(p, retired), + parent(p, q), + !taxStatus(q, retired). + +direct rule createChild(p): + children(p, newPerson) = unknown, + equals(newPerson, newPerson) = unknown + ~> new q, + children(p, q) = true, + taxStatus(q, child) = true. + +indiv family. +Family(family). +members(family, anne). +members(family, bob). +members(family, ciri). +children(anne, ciri). +?children(bob, ciri). +default children(ciri, *): false. +taxStatus(anne, adult). +age(anne, 35). +bobAge: 27. +age(bob, bobAge). +!age(ciri, bobAge). + +scope Family = 1, Person += 5..10. +`; + +const rootStore = new RootStore(initialValue); + +const app = ( + + + + + + +); + +render(app, document.getElementById('app')); diff --git a/subprojects/frontend/src/language/folding.ts b/subprojects/frontend/src/language/folding.ts new file mode 100644 index 00000000..5d51f796 --- /dev/null +++ b/subprojects/frontend/src/language/folding.ts @@ -0,0 +1,115 @@ +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 + */ +function foldWithSibling(node: SyntaxNode): FoldRange | null { + const { parent } = node; + if (parent === null) { + return null; + } + const { firstChild } = parent; + if (firstChild === null) { + return null; + } + const { cursor } = firstChild; + let nSiblings = 0; + while (cursor.nextSibling()) { + if (cursor.type === node.type) { + nSiblings += 1; + } + if (nSiblings >= 2) { + return { + from: node.from, + to: node.to, + }; + } + } + return null; +} + +export function foldWholeNode(node: SyntaxNode): FoldRange { + return { + from: node.from, + to: node.to, + }; +} + +export function foldConjunction(node: SyntaxNode): FoldRange | null { + if (node.parent?.type?.name === 'PredicateBody') { + return foldWithSibling(node); + } + return foldWholeNode(node); +} diff --git a/subprojects/frontend/src/language/indentation.ts b/subprojects/frontend/src/language/indentation.ts new file mode 100644 index 00000000..6d36ed3b --- /dev/null +++ b/subprojects/frontend/src/language/indentation.ts @@ -0,0 +1,87 @@ +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 indentPredicateOrRule(context: TreeIndentContext): number { + const clauseIndent = indentDeclarationStrategy(context, 1); + if (/^\s+[;.]/.exec(context.textAfter) !== null) { + return clauseIndent - 2; + } + if (/^\s+(~>)/.exec(context.textAfter) !== null) { + return clauseIndent - 3; + } + return clauseIndent; +} diff --git a/subprojects/frontend/src/language/problem.grammar b/subprojects/frontend/src/language/problem.grammar new file mode 100644 index 00000000..1ace2872 --- /dev/null +++ b/subprojects/frontend/src/language/problem.grammar @@ -0,0 +1,149 @@ +@detectDelim + +@external prop implicitCompletion from '../../../../src/language/props.ts' + +@top Problem { statement* } + +statement { + ProblemDeclaration { + ckw<"problem"> QualifiedName "." + } | + ClassDefinition { + ckw<"abstract">? ckw<"class"> RelationName + (ckw<"extends"> sep<",", RelationName>)? + (ClassBody { "{" ReferenceDeclaration* "}" } | ".") + } | + EnumDefinition { + ckw<"enum"> RelationName + (EnumBody { "{" sep<",", IndividualNodeName> "}" } | ".") + } | + PredicateDefinition { + (ckw<"error"> ckw<"pred">? | ckw<"direct">? ckw<"pred">) + RelationName ParameterList? + PredicateBody { ("<->" sep)? "." } + } | + RuleDefinition { + ckw<"direct">? ckw<"rule"> + RuleName ParameterList? + RuleBody { ":" sep "~>" sep "." } + } | + Assertion { + kw<"default">? (NotOp | UnknownOp)? RelationName + ParameterList (":" LogicValue)? "." + } | + NodeValueAssertion { + IndividualNodeName ":" Constant "." + } | + IndividualDeclaration { + ckw<"indiv"> sep<",", IndividualNodeName> "." + } | + ScopeDeclaration { + kw<"scope"> sep<",", ScopeElement> "." + } +} + +ReferenceDeclaration { + (kw<"refers"> | kw<"contains">)? + RelationName + RelationName + ( "[" Multiplicity? "]" )? + (kw<"opposite"> RelationName)? + ";"? +} + +Parameter { RelationName? VariableName } + +Conjunction { ("," | Literal)+ } + +OrOp { ";" } + +Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? } + +Atom { RelationName "+"? ParameterList } + +Action { ("," | ActionLiteral)+ } + +ActionLiteral { + ckw<"new"> VariableName | + ckw<"delete"> VariableName | + Literal +} + +Argument { VariableName | Constant } + +AssertionArgument { NodeName | StarArgument | Constant } + +Constant { Real | String } + +LogicValue { + ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> +} + +ScopeElement { RelationName ("=" | "+=") Multiplicity } + +Multiplicity { (IntMult "..")? (IntMult | StarMult)} + +RelationName { QualifiedName } + +RuleName { QualifiedName } + +IndividualNodeName { QualifiedName } + +VariableName { QualifiedName } + +NodeName { QualifiedName } + +QualifiedName[implicitCompletion=true] { identifier ("::" identifier)* } + +kw { @specialize[@name={term},implicitCompletion=true] } + +ckw { @extend[@name={term},implicitCompletion=true] } + +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 } + + String { + "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" | + "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\"" + } + + NotOp { "!" } + + UnknownOp { "?" } + + StarArgument { "*" } + + "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" "~>" +} + +@detectDelim diff --git a/subprojects/frontend/src/language/problemLanguageSupport.ts b/subprojects/frontend/src/language/problemLanguageSupport.ts new file mode 100644 index 00000000..b858ba91 --- /dev/null +++ b/subprojects/frontend/src/language/problemLanguageSupport.ts @@ -0,0 +1,92 @@ +import { styleTags, tags as t } from '@codemirror/highlight'; +import { + foldInside, + foldNodeProp, + indentNodeProp, + indentUnit, + LanguageSupport, + LRLanguage, +} from '@codemirror/language'; +import { LRParser } from '@lezer/lr'; + +import { parser } from '../../build/generated/sources/lezer/problem'; +import { + foldBlockComment, + foldConjunction, + foldDeclaration, + foldWholeNode, +} from './folding'; +import { + indentBlockComment, + indentDeclaration, + indentPredicateOrRule, +} from './indentation'; + +const parserWithMetadata = (parser as LRParser).configure({ + props: [ + styleTags({ + LineComment: t.lineComment, + BlockComment: t.blockComment, + 'problem class enum pred rule indiv scope': t.definitionKeyword, + 'abstract extends refers contains opposite error direct default': t.modifier, + 'true false unknown error': t.keyword, + 'new delete': t.operatorKeyword, + NotOp: t.keyword, + UnknownOp: t.keyword, + OrOp: t.keyword, + StarArgument: t.keyword, + 'IntMult StarMult Real': t.number, + StarMult: t.number, + String: t.string, + 'RelationName/QualifiedName': t.typeName, + 'RuleName/QualifiedName': t.macroName, + 'IndividualNodeName/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: indentPredicateOrRule, + RuleBody: indentPredicateOrRule, + BlockComment: indentBlockComment, + }), + foldNodeProp.add({ + ClassBody: foldInside, + EnumBody: foldInside, + ParameterList: foldInside, + PredicateBody: foldInside, + RuleBody: foldInside, + Conjunction: foldConjunction, + Action: foldWholeNode, + 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, [ + indentUnit.of(' '), + ]); +} diff --git a/subprojects/frontend/src/language/props.ts b/subprojects/frontend/src/language/props.ts new file mode 100644 index 00000000..8e488bf5 --- /dev/null +++ b/subprojects/frontend/src/language/props.ts @@ -0,0 +1,7 @@ +import { NodeProp } from '@lezer/common'; + +export const implicitCompletion = new NodeProp({ + deserialize(s: string) { + return s === 'true'; + }, +}); diff --git a/subprojects/frontend/src/theme/EditorTheme.ts b/subprojects/frontend/src/theme/EditorTheme.ts new file mode 100644 index 00000000..294192fa --- /dev/null +++ b/subprojects/frontend/src/theme/EditorTheme.ts @@ -0,0 +1,47 @@ +import type { PaletteMode } from '@mui/material'; + +import cssVariables from '../themeVariables.module.scss'; + +export enum EditorTheme { + Light, + Dark, +} + +export class EditorThemeData { + className: string; + + paletteMode: PaletteMode; + + toggleDarkMode: EditorTheme; + + foreground!: string; + + foregroundHighlight!: string; + + background!: string; + + primary!: string; + + secondary!: string; + + constructor(className: string, paletteMode: PaletteMode, toggleDarkMode: EditorTheme) { + this.className = className; + this.paletteMode = paletteMode; + this.toggleDarkMode = toggleDarkMode; + Reflect.ownKeys(this).forEach((key) => { + if (!Reflect.get(this, key)) { + const cssKey = `${this.className}--${key.toString()}`; + if (cssKey in cssVariables) { + Reflect.set(this, key, cssVariables[cssKey]); + } + } + }); + } +} + +export const DEFAULT_THEME = EditorTheme.Dark; + +export const EDITOR_THEMES: { [key in EditorTheme]: EditorThemeData } = { + [EditorTheme.Light]: new EditorThemeData('light', 'light', EditorTheme.Dark), + [EditorTheme.Dark]: new EditorThemeData('dark', 'dark', EditorTheme.Light), +}; diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..f5b50be1 --- /dev/null +++ b/subprojects/frontend/src/theme/ThemeProvider.tsx @@ -0,0 +1,15 @@ +import { observer } from 'mobx-react-lite'; +import { ThemeProvider as MaterialUiThemeProvider } from '@mui/material/styles'; +import React from 'react'; + +import { useRootStore } from '../RootStore'; + +export const ThemeProvider: React.FC = observer(({ children }) => { + const { themeStore } = useRootStore(); + + return ( + + {children} + + ); +}); diff --git a/subprojects/frontend/src/theme/ThemeStore.ts b/subprojects/frontend/src/theme/ThemeStore.ts new file mode 100644 index 00000000..ffaf6dde --- /dev/null +++ b/subprojects/frontend/src/theme/ThemeStore.ts @@ -0,0 +1,64 @@ +import { makeAutoObservable } from 'mobx'; +import { + Theme, + createTheme, + responsiveFontSizes, +} from '@mui/material/styles'; + +import { + EditorTheme, + EditorThemeData, + DEFAULT_THEME, + EDITOR_THEMES, +} from './EditorTheme'; + +export class ThemeStore { + currentTheme: EditorTheme = DEFAULT_THEME; + + constructor() { + makeAutoObservable(this); + } + + toggleDarkMode(): void { + this.currentTheme = this.currentThemeData.toggleDarkMode; + } + + private get currentThemeData(): EditorThemeData { + return EDITOR_THEMES[this.currentTheme]; + } + + get materialUiTheme(): Theme { + const themeData = this.currentThemeData; + const materialUiTheme = createTheme({ + palette: { + mode: themeData.paletteMode, + background: { + default: themeData.background, + paper: themeData.background, + }, + primary: { + main: themeData.primary, + }, + secondary: { + main: themeData.secondary, + }, + error: { + main: themeData.secondary, + }, + text: { + primary: themeData.foregroundHighlight, + secondary: themeData.foreground, + }, + }, + }); + return responsiveFontSizes(materialUiTheme); + } + + get darkMode(): boolean { + return this.currentThemeData.paletteMode === 'dark'; + } + + get className(): string { + return this.currentThemeData.className; + } +} diff --git a/subprojects/frontend/src/themeVariables.module.scss b/subprojects/frontend/src/themeVariables.module.scss new file mode 100644 index 00000000..85af4219 --- /dev/null +++ b/subprojects/frontend/src/themeVariables.module.scss @@ -0,0 +1,9 @@ +@import './themes'; + +:export { + @each $themeName, $theme in $themes { + @each $variable, $value in $theme { + #{$themeName}--#{$variable}: $value, + } + } +} diff --git a/subprojects/frontend/src/themes.scss b/subprojects/frontend/src/themes.scss new file mode 100644 index 00000000..a30f1de3 --- /dev/null +++ b/subprojects/frontend/src/themes.scss @@ -0,0 +1,38 @@ +$themes: ( + 'dark': ( + 'foreground': #abb2bf, + 'foregroundHighlight': #eeffff, + 'background': #212121, + 'primary': #56b6c2, + 'secondary': #ff5370, + 'keyword': #56b6c2, + 'predicate': #d6e9ff, + 'variable': #c8ae9d, + 'uniqueNode': #d6e9ff, + 'number': #6e88a6, + 'delimiter': #707787, + 'comment': #5c6370, + 'cursor': #56b6c2, + 'selection': #3e4452, + 'currentLine': rgba(0, 0, 0, 0.2), + 'lineNumber': #5c6370, + ), + 'light': ( + 'foreground': #abb2bf, + 'background': #282c34, + 'paper': #21252b, + 'primary': #56b6c2, + 'secondary': #ff5370, + 'keyword': #56b6c2, + 'predicate': #d6e9ff, + 'variable': #c8ae9d, + 'uniqueNode': #d6e9ff, + 'number': #6e88a6, + 'delimiter': #56606d, + 'comment': #55606d, + 'cursor': #f3efe7, + 'selection': #3e4452, + 'currentLine': #2c323c, + 'lineNumber': #5c6370, + ), +); diff --git a/subprojects/frontend/src/utils/ConditionVariable.ts b/subprojects/frontend/src/utils/ConditionVariable.ts new file mode 100644 index 00000000..0910dfa6 --- /dev/null +++ b/subprojects/frontend/src/utils/ConditionVariable.ts @@ -0,0 +1,64 @@ +import { getLogger } from './logger'; +import { PendingTask } from './PendingTask'; + +const log = getLogger('utils.ConditionVariable'); + +export type Condition = () => boolean; + +export class ConditionVariable { + condition: Condition; + + defaultTimeout: number; + + listeners: PendingTask[] = []; + + constructor(condition: Condition, defaultTimeout = 0) { + this.condition = condition; + this.defaultTimeout = defaultTimeout; + } + + async waitFor(timeoutMs: number | null = null): Promise { + if (this.condition()) { + return; + } + const timeoutOrDefault = timeoutMs || this.defaultTimeout; + let nowMs = Date.now(); + const endMs = nowMs + timeoutOrDefault; + while (!this.condition() && nowMs < endMs) { + const remainingMs = endMs - nowMs; + const promise = new Promise((resolve, reject) => { + if (this.condition()) { + resolve(); + return; + } + const task = new PendingTask(resolve, reject, remainingMs); + this.listeners.push(task); + }); + // We must keep waiting until the update has completed, + // so the tasks can't be started in parallel. + // eslint-disable-next-line no-await-in-loop + await promise; + nowMs = Date.now(); + } + if (!this.condition()) { + log.error('Condition still does not hold after', timeoutOrDefault, 'ms'); + throw new Error('Failed to wait for condition'); + } + } + + notifyAll(): void { + this.clearListenersWith((listener) => listener.resolve()); + } + + rejectAll(error: unknown): void { + this.clearListenersWith((listener) => listener.reject(error)); + } + + private clearListenersWith(callback: (listener: PendingTask) => void) { + // Copy `listeners` so that we don't get into a race condition + // if one of the listeners adds another listener. + const { listeners } = this; + this.listeners = []; + listeners.forEach(callback); + } +} diff --git a/subprojects/frontend/src/utils/PendingTask.ts b/subprojects/frontend/src/utils/PendingTask.ts new file mode 100644 index 00000000..51b79fb0 --- /dev/null +++ b/subprojects/frontend/src/utils/PendingTask.ts @@ -0,0 +1,60 @@ +import { getLogger } from './logger'; + +const log = getLogger('utils.PendingTask'); + +export class PendingTask { + private readonly resolveCallback: (value: T) => void; + + private readonly rejectCallback: (reason?: unknown) => void; + + private resolved = false; + + private timeout: number | null; + + constructor( + resolveCallback: (value: T) => void, + rejectCallback: (reason?: unknown) => void, + timeoutMs?: number, + timeoutCallback?: () => void, + ) { + this.resolveCallback = resolveCallback; + this.rejectCallback = rejectCallback; + if (timeoutMs) { + this.timeout = setTimeout(() => { + if (!this.resolved) { + this.reject(new Error('Request timed out')); + if (timeoutCallback) { + timeoutCallback(); + } + } + }, timeoutMs); + } else { + this.timeout = null; + } + } + + resolve(value: T): void { + if (this.resolved) { + log.warn('Trying to resolve already resolved promise'); + return; + } + this.markResolved(); + this.resolveCallback(value); + } + + reject(reason?: unknown): void { + if (this.resolved) { + log.warn('Trying to reject already resolved promise'); + return; + } + this.markResolved(); + this.rejectCallback(reason); + } + + private markResolved() { + this.resolved = true; + if (this.timeout !== null) { + clearTimeout(this.timeout); + } + } +} diff --git a/subprojects/frontend/src/utils/Timer.ts b/subprojects/frontend/src/utils/Timer.ts new file mode 100644 index 00000000..8f653070 --- /dev/null +++ b/subprojects/frontend/src/utils/Timer.ts @@ -0,0 +1,33 @@ +export class Timer { + readonly callback: () => void; + + readonly defaultTimeout: number; + + timeout: number | null = null; + + constructor(callback: () => void, defaultTimeout = 0) { + this.callback = () => { + this.timeout = null; + callback(); + }; + this.defaultTimeout = defaultTimeout; + } + + schedule(timeout: number | null = null): void { + if (this.timeout === null) { + this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout); + } + } + + reschedule(timeout: number | null = null): void { + this.cancel(); + this.schedule(timeout); + } + + cancel(): void { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} diff --git a/subprojects/frontend/src/utils/logger.ts b/subprojects/frontend/src/utils/logger.ts new file mode 100644 index 00000000..306d122c --- /dev/null +++ b/subprojects/frontend/src/utils/logger.ts @@ -0,0 +1,49 @@ +import styles, { CSPair } from 'ansi-styles'; +import log from 'loglevel'; +import * as prefix from 'loglevel-plugin-prefix'; + +const colors: Partial> = { + TRACE: styles.magenta, + DEBUG: styles.cyan, + INFO: styles.blue, + WARN: styles.yellow, + ERROR: styles.red, +}; + +prefix.reg(log); + +if (DEBUG) { + log.setLevel(log.levels.DEBUG); +} else { + log.setLevel(log.levels.WARN); +} + +if ('chrome' in window) { + // Only Chromium supports console ANSI escape sequences. + prefix.apply(log, { + format(level, name, timestamp) { + const formattedTimestamp = `${styles.gray.open}[${timestamp.toString()}]${styles.gray.close}`; + const levelColor = colors[level.toUpperCase()] || styles.red; + const formattedLevel = `${levelColor.open}${level}${levelColor.close}`; + const formattedName = `${styles.green.open}(${name || 'root'})${styles.green.close}`; + return `${formattedTimestamp} ${formattedLevel} ${formattedName}`; + }, + }); +} else { + prefix.apply(log, { + template: '[%t] %l (%n)', + }); +} + +const appLogger = log.getLogger(PACKAGE_NAME); + +appLogger.info('Version:', PACKAGE_NAME, PACKAGE_VERSION); +appLogger.info('Debug mode:', DEBUG); + +export function getLoggerFromRoot(name: string | symbol): log.Logger { + return log.getLogger(name); +} + +export function getLogger(name: string | symbol): log.Logger { + return getLoggerFromRoot(`${PACKAGE_NAME}.${name.toString()}`); +} diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/frontend/src/xtext/ContentAssistService.ts @@ -0,0 +1,219 @@ +import type { + Completion, + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import { syntaxTree } from '@codemirror/language'; +import type { Transaction } from '@codemirror/state'; +import escapeStringRegexp from 'escape-string-regexp'; + +import { implicitCompletion } from '../language/props'; +import type { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +import type { ContentAssistEntry } from './xtextServiceResults'; + +const PROPOSALS_LIMIT = 1000; + +const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; + +const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; + +const log = getLogger('xtext.ContentAssistService'); + +interface IFoundToken { + from: number; + + to: number; + + implicitCompletion: boolean; + + text: string; +} + +function findToken({ pos, state }: CompletionContext): IFoundToken | null { + const token = syntaxTree(state).resolveInner(pos, -1); + if (token === null) { + return null; + } + if (token.firstChild !== null) { + // We only autocomplete terminal nodes. If the current node is nonterminal, + // returning `null` makes us autocomplete with the empty prefix instead. + return null; + } + return { + from: token.from, + to: token.to, + implicitCompletion: token.type.prop(implicitCompletion) || false, + text: state.sliceDoc(token.from, token.to), + }; +} + +function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { + return token !== null + && token.implicitCompletion + && context.pos - token.from >= 2; +} + +function computeSpan(prefix: string, entryCount: number): RegExp { + const escapedPrefix = escapeStringRegexp(prefix); + if (entryCount < PROPOSALS_LIMIT) { + // Proposals with the current prefix fit the proposals limit. + // We can filter client side as long as the current prefix is preserved. + return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); + } + // The current prefix overflows the proposals limits, + // so we have to fetch the completions again on the next keypress. + // Hopefully, it'll return a shorter list and we'll be able to filter client side. + return new RegExp(`^${escapedPrefix}$`); +} + +function createCompletion(entry: ContentAssistEntry): Completion { + let boost: number; + switch (entry.kind) { + case 'KEYWORD': + // Some hard-to-type operators should be on top. + boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; + break; + case 'TEXT': + case 'SNIPPET': + boost = -90; + break; + default: { + // Penalize qualified names (vs available unqualified names). + const extraSegments = entry.proposal.match(/::/g)?.length || 0; + boost = Math.max(-5 * extraSegments, -50); + } + break; + } + return { + label: entry.proposal, + detail: entry.description, + info: entry.documentation, + type: entry.kind?.toLowerCase(), + boost, + }; +} + +export class ContentAssistService { + private readonly updateService: UpdateService; + + private lastCompletion: CompletionResult | null = null; + + constructor(updateService: UpdateService) { + this.updateService = updateService; + } + + onTransaction(transaction: Transaction): void { + if (this.shouldInvalidateCachedCompletion(transaction)) { + this.lastCompletion = null; + } + } + + async contentAssist(context: CompletionContext): Promise { + const tokenBefore = findToken(context); + if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { + return { + from: context.pos, + options: [], + }; + } + let range: { from: number, to: number }; + let prefix = ''; + if (tokenBefore === null) { + range = { + from: context.pos, + to: context.pos, + }; + prefix = ''; + } else { + range = { + from: tokenBefore.from, + to: tokenBefore.to, + }; + const prefixLength = context.pos - tokenBefore.from; + if (prefixLength > 0) { + prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); + } + } + if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { + log.trace('Returning cached completion result'); + // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` + return { + ...this.lastCompletion as CompletionResult, + ...range, + }; + } + this.lastCompletion = null; + const entries = await this.updateService.fetchContentAssist({ + resource: this.updateService.resourceName, + serviceType: 'assist', + caretOffset: context.pos, + proposalsLimit: PROPOSALS_LIMIT, + }, context); + if (context.aborted) { + return { + ...range, + options: [], + }; + } + const options: Completion[] = []; + entries.forEach((entry) => { + if (prefix === entry.prefix) { + // Xtext will generate completions that do not complete the current token, + // e.g., `(` after trying to complete an indetifier, + // but we ignore those, since CodeMirror won't filter for them anyways. + options.push(createCompletion(entry)); + } + }); + log.debug('Fetched', options.length, 'completions from server'); + this.lastCompletion = { + ...range, + options, + span: computeSpan(prefix, entries.length), + }; + return this.lastCompletion; + } + + private shouldReturnCachedCompletion( + token: { from: number, to: number, text: string } | null, + ): boolean { + if (token === null || this.lastCompletion === null) { + return false; + } + const { from, to, text } = token; + const { from: lastFrom, to: lastTo, span } = this.lastCompletion; + if (!lastTo) { + return true; + } + const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); + return from >= transformedFrom + && to <= transformedTo + && typeof span !== 'undefined' + && span.exec(text) !== null; + } + + private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { + if (!transaction.docChanged || this.lastCompletion === null) { + return false; + } + const { from: lastFrom, to: lastTo } = this.lastCompletion; + if (!lastTo) { + return true; + } + const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); + let invalidate = false; + transaction.changes.iterChangedRanges((fromA, toA) => { + if (fromA < transformedFrom || toA > transformedTo) { + invalidate = true; + } + }); + return invalidate; + } + + private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { + const changes = this.updateService.computeChangesSinceLastUpdate(); + const transformedFrom = changes.mapPos(lastFrom); + const transformedTo = changes.mapPos(lastTo, 1); + return [transformedFrom, transformedTo]; + } +} diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts new file mode 100644 index 00000000..dfbb4a19 --- /dev/null +++ b/subprojects/frontend/src/xtext/HighlightingService.ts @@ -0,0 +1,37 @@ +import type { EditorStore } from '../editor/EditorStore'; +import type { IHighlightRange } from '../editor/semanticHighlighting'; +import type { UpdateService } from './UpdateService'; +import { highlightingResult } from './xtextServiceResults'; + +export class HighlightingService { + private readonly store: EditorStore; + + private readonly updateService: UpdateService; + + constructor(store: EditorStore, updateService: UpdateService) { + this.store = store; + this.updateService = updateService; + } + + onPush(push: unknown): void { + const { regions } = highlightingResult.parse(push); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + const ranges: IHighlightRange[] = []; + regions.forEach(({ offset, length, styleClasses }) => { + if (styleClasses.length === 0) { + return; + } + const from = allChanges.mapPos(offset); + const to = allChanges.mapPos(offset + length); + if (to <= from) { + return; + } + ranges.push({ + from, + to, + classes: styleClasses, + }); + }); + this.store.updateSemanticHighlighting(ranges); + } +} diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts new file mode 100644 index 00000000..bc865537 --- /dev/null +++ b/subprojects/frontend/src/xtext/OccurrencesService.ts @@ -0,0 +1,127 @@ +import { Transaction } from '@codemirror/state'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { IOccurrence } from '../editor/findOccurrences'; +import type { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +import { Timer } from '../utils/Timer'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; +import { + isConflictResult, + occurrencesResult, + TextRegion, +} from './xtextServiceResults'; + +const FIND_OCCURRENCES_TIMEOUT_MS = 1000; + +// Must clear occurrences asynchronously from `onTransaction`, +// because we must not emit a conflicting transaction when handling the pending transaction. +const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; + +const log = getLogger('xtext.OccurrencesService'); + +function transformOccurrences(regions: TextRegion[]): IOccurrence[] { + const occurrences: IOccurrence[] = []; + regions.forEach(({ offset, length }) => { + if (length > 0) { + occurrences.push({ + from: offset, + to: offset + length, + }); + } + }); + return occurrences; +} + +export class OccurrencesService { + private readonly store: EditorStore; + + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updateService: UpdateService; + + private hasOccurrences = false; + + private readonly findOccurrencesTimer = new Timer(() => { + this.handleFindOccurrences(); + }, FIND_OCCURRENCES_TIMEOUT_MS); + + private readonly clearOccurrencesTimer = new Timer(() => { + this.clearOccurrences(); + }, CLEAR_OCCURRENCES_TIMEOUT_MS); + + constructor( + store: EditorStore, + webSocketClient: XtextWebSocketClient, + updateService: UpdateService, + ) { + this.store = store; + this.webSocketClient = webSocketClient; + this.updateService = updateService; + } + + onTransaction(transaction: Transaction): void { + if (transaction.docChanged) { + this.clearOccurrencesTimer.schedule(); + this.findOccurrencesTimer.reschedule(); + } + if (transaction.isUserEvent('select')) { + this.findOccurrencesTimer.reschedule(); + } + } + + private handleFindOccurrences() { + this.clearOccurrencesTimer.cancel(); + this.updateOccurrences().catch((error) => { + log.error('Unexpected error while updating occurrences', error); + this.clearOccurrences(); + }); + } + + private async updateOccurrences() { + await this.updateService.update(); + const result = await this.webSocketClient.send({ + resource: this.updateService.resourceName, + serviceType: 'occurrences', + expectedStateId: this.updateService.xtextStateId, + caretOffset: this.store.state.selection.main.head, + }); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + if (!allChanges.empty || isConflictResult(result, 'canceled')) { + // Stale occurrences result, the user already made some changes. + // We can safely ignore the occurrences and schedule a new find occurrences call. + this.clearOccurrences(); + this.findOccurrencesTimer.schedule(); + return; + } + const parsedOccurrencesResult = occurrencesResult.safeParse(result); + if (!parsedOccurrencesResult.success) { + log.error( + 'Unexpected occurences result', + result, + 'not an OccurrencesResult: ', + parsedOccurrencesResult.error, + ); + this.clearOccurrences(); + return; + } + const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; + if (stateId !== this.updateService.xtextStateId) { + log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); + this.clearOccurrences(); + return; + } + const write = transformOccurrences(writeRegions); + const read = transformOccurrences(readRegions); + this.hasOccurrences = write.length > 0 || read.length > 0; + log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); + this.store.updateOccurrences(write, read); + } + + private clearOccurrences() { + if (this.hasOccurrences) { + this.store.updateOccurrences([], []); + this.hasOccurrences = false; + } + } +} diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/frontend/src/xtext/UpdateService.ts @@ -0,0 +1,363 @@ +import { + ChangeDesc, + ChangeSet, + ChangeSpec, + StateEffect, + Transaction, +} from '@codemirror/state'; +import { nanoid } from 'nanoid'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { XtextWebSocketClient } from './XtextWebSocketClient'; +import { ConditionVariable } from '../utils/ConditionVariable'; +import { getLogger } from '../utils/logger'; +import { Timer } from '../utils/Timer'; +import { + ContentAssistEntry, + contentAssistResult, + documentStateResult, + formattingResult, + isConflictResult, +} from './xtextServiceResults'; + +const UPDATE_TIMEOUT_MS = 500; + +const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; + +const log = getLogger('xtext.UpdateService'); + +const setDirtyChanges = StateEffect.define(); + +export interface IAbortSignal { + aborted: boolean; +} + +export class UpdateService { + resourceName: string; + + xtextStateId: string | null = null; + + private readonly store: EditorStore; + + /** + * The changes being synchronized to the server if a full or delta text update is running, + * `null` otherwise. + */ + private pendingUpdate: ChangeSet | null = null; + + /** + * Local changes not yet sychronized to the server and not part of the running update, if any. + */ + private dirtyChanges: ChangeSet; + + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updatedCondition = new ConditionVariable( + () => this.pendingUpdate === null && this.xtextStateId !== null, + WAIT_FOR_UPDATE_TIMEOUT_MS, + ); + + private readonly idleUpdateTimer = new Timer(() => { + this.handleIdleUpdate(); + }, UPDATE_TIMEOUT_MS); + + constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { + this.resourceName = `${nanoid(7)}.problem`; + this.store = store; + this.dirtyChanges = this.newEmptyChangeSet(); + this.webSocketClient = webSocketClient; + } + + onReconnect(): void { + this.xtextStateId = null; + this.updateFullText().catch((error) => { + log.error('Unexpected error during initial update', error); + }); + } + + onTransaction(transaction: Transaction): void { + const setDirtyChangesEffect = transaction.effects.find( + (effect) => effect.is(setDirtyChanges), + ) as StateEffect | undefined; + if (setDirtyChangesEffect) { + const { value } = setDirtyChangesEffect; + if (this.pendingUpdate !== null) { + this.pendingUpdate = ChangeSet.empty(value.length); + } + this.dirtyChanges = value; + return; + } + if (transaction.docChanged) { + this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); + this.idleUpdateTimer.reschedule(); + } + } + + /** + * Computes the summary of any changes happened since the last complete update. + * + * The result reflects any changes that happened since the `xtextStateId` + * version was uploaded to the server. + * + * @return the summary of changes since the last update + */ + computeChangesSinceLastUpdate(): ChangeDesc { + return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; + } + + private handleIdleUpdate() { + if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { + return; + } + if (this.pendingUpdate === null) { + this.update().catch((error) => { + log.error('Unexpected error during scheduled update', error); + }); + } + this.idleUpdateTimer.reschedule(); + } + + private newEmptyChangeSet() { + return ChangeSet.of([], this.store.state.doc.length); + } + + async updateFullText(): Promise { + await this.withUpdate(() => this.doUpdateFullText()); + } + + private async doUpdateFullText(): Promise<[string, void]> { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'update', + fullText: this.store.state.doc.sliceString(0), + }); + const { stateId } = documentStateResult.parse(result); + return [stateId, undefined]; + } + + /** + * Makes sure that the document state on the server reflects recent + * local changes. + * + * Performs either an update with delta text or a full text update if needed. + * If there are not local dirty changes, the promise resolves immediately. + * + * @return a promise resolving when the update is completed + */ + async update(): Promise { + await this.prepareForDeltaUpdate(); + const delta = this.computeDelta(); + if (delta === null) { + return; + } + log.trace('Editor delta', delta); + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'update', + requiredStateId: this.xtextStateId, + ...delta, + }); + const parsedDocumentStateResult = documentStateResult.safeParse(result); + if (parsedDocumentStateResult.success) { + return [parsedDocumentStateResult.data.stateId, undefined]; + } + if (isConflictResult(result, 'invalidStateId')) { + return this.doFallbackToUpdateFullText(); + } + throw parsedDocumentStateResult.error; + }); + } + + private doFallbackToUpdateFullText() { + if (this.pendingUpdate === null) { + throw new Error('Only a pending update can be extended'); + } + log.warn('Delta update failed, performing full text update'); + this.xtextStateId = null; + this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); + this.dirtyChanges = this.newEmptyChangeSet(); + return this.doUpdateFullText(); + } + + async fetchContentAssist( + params: Record, + signal: IAbortSignal, + ): Promise { + await this.prepareForDeltaUpdate(); + if (signal.aborted) { + return []; + } + const delta = this.computeDelta(); + if (delta !== null) { + log.trace('Editor delta', delta); + const entries = await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: this.xtextStateId, + ...delta, + }); + const parsedContentAssistResult = contentAssistResult.safeParse(result); + if (parsedContentAssistResult.success) { + const { stateId, entries: resultEntries } = parsedContentAssistResult.data; + return [stateId, resultEntries]; + } + if (isConflictResult(result, 'invalidStateId')) { + log.warn('Server state invalid during content assist'); + const [newStateId] = await this.doFallbackToUpdateFullText(); + // We must finish this state update transaction to prepare for any push events + // before querying for content assist, so we just return `null` and will query + // the content assist service later. + return [newStateId, null]; + } + throw parsedContentAssistResult.error; + }); + if (entries !== null) { + return entries; + } + if (signal.aborted) { + return []; + } + } + // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` + return this.doFetchContentAssist(params, this.xtextStateId as string); + } + + private async doFetchContentAssist(params: Record, expectedStateId: string) { + const result = await this.webSocketClient.send({ + ...params, + requiredStateId: expectedStateId, + }); + const { stateId, entries } = contentAssistResult.parse(result); + if (stateId !== expectedStateId) { + throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); + } + return entries; + } + + async formatText(): Promise { + await this.update(); + let { from, to } = this.store.state.selection.main; + if (to <= from) { + from = 0; + to = this.store.state.doc.length; + } + log.debug('Formatting from', from, 'to', to); + await this.withUpdate(async () => { + const result = await this.webSocketClient.send({ + resource: this.resourceName, + serviceType: 'format', + selectionStart: from, + selectionEnd: to, + }); + const { stateId, formattedText } = formattingResult.parse(result); + this.applyBeforeDirtyChanges({ + from, + to, + insert: formattedText, + }); + return [stateId, null]; + }); + } + + private computeDelta() { + if (this.dirtyChanges.empty) { + return null; + } + let minFromA = Number.MAX_SAFE_INTEGER; + let maxToA = 0; + let minFromB = Number.MAX_SAFE_INTEGER; + let maxToB = 0; + this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { + minFromA = Math.min(minFromA, fromA); + maxToA = Math.max(maxToA, toA); + minFromB = Math.min(minFromB, fromB); + maxToB = Math.max(maxToB, toB); + }); + return { + deltaOffset: minFromA, + deltaReplaceLength: maxToA - minFromA, + deltaText: this.store.state.doc.sliceString(minFromB, maxToB), + }; + } + + private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { + const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; + const revertChanges = pendingChanges.invert(this.store.state.doc); + const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); + const redoChanges = pendingChanges.map(applyBefore.desc); + const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); + this.store.dispatch({ + changes: changeSet, + effects: [ + setDirtyChanges.of(redoChanges), + ], + }); + } + + /** + * Executes an asynchronous callback that updates the state on the server. + * + * Ensures that updates happen sequentially and manages `pendingUpdate` + * and `dirtyChanges` to reflect changes being synchronized to the server + * and not yet synchronized to the server, respectively. + * + * Optionally, `callback` may return a second value that is retured by this function. + * + * Once the remote procedure call to update the server state finishes + * and returns the new `stateId`, `callback` must return _immediately_ + * to ensure that the local `stateId` is updated likewise to be able to handle + * push messages referring to the new `stateId` from the server. + * If additional work is needed to compute the second value in some cases, + * use `T | null` instead of `T` as a return type and signal the need for additional + * computations by returning `null`. Thus additional computations can be performed + * outside of the critical section. + * + * @param callback the asynchronous callback that updates the server state + * @return a promise resolving to the second value returned by `callback` + */ + private async withUpdate(callback: () => Promise<[string, T]>): Promise { + if (this.pendingUpdate !== null) { + throw new Error('Another update is pending, will not perform update'); + } + this.pendingUpdate = this.dirtyChanges; + this.dirtyChanges = this.newEmptyChangeSet(); + let newStateId: string | null = null; + try { + let result: T; + [newStateId, result] = await callback(); + this.xtextStateId = newStateId; + this.pendingUpdate = null; + this.updatedCondition.notifyAll(); + return result; + } catch (e) { + log.error('Error while update', e); + if (this.pendingUpdate === null) { + log.error('pendingUpdate was cleared during update'); + } else { + this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); + } + this.pendingUpdate = null; + this.webSocketClient.forceReconnectOnError(); + this.updatedCondition.rejectAll(e); + throw e; + } + } + + /** + * Ensures that there is some state available on the server (`xtextStateId`) + * and that there is not pending update. + * + * After this function resolves, a delta text update is possible. + * + * @return a promise resolving when there is a valid state id but no pending update + */ + private async prepareForDeltaUpdate() { + // If no update is pending, but the full text hasn't been uploaded to the server yet, + // we must start a full text upload. + if (this.pendingUpdate === null && this.xtextStateId === null) { + await this.updateFullText(); + } + await this.updatedCondition.waitFor(); + } +} diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts new file mode 100644 index 00000000..ff7d3700 --- /dev/null +++ b/subprojects/frontend/src/xtext/ValidationService.ts @@ -0,0 +1,39 @@ +import type { Diagnostic } from '@codemirror/lint'; + +import type { EditorStore } from '../editor/EditorStore'; +import type { UpdateService } from './UpdateService'; +import { validationResult } from './xtextServiceResults'; + +export class ValidationService { + private readonly store: EditorStore; + + private readonly updateService: UpdateService; + + constructor(store: EditorStore, updateService: UpdateService) { + this.store = store; + this.updateService = updateService; + } + + onPush(push: unknown): void { + const { issues } = validationResult.parse(push); + const allChanges = this.updateService.computeChangesSinceLastUpdate(); + const diagnostics: Diagnostic[] = []; + issues.forEach(({ + offset, + length, + severity, + description, + }) => { + if (severity === 'ignore') { + return; + } + diagnostics.push({ + from: allChanges.mapPos(offset), + to: allChanges.mapPos(offset + length), + severity, + message: description, + }); + }); + this.store.updateDiagnostics(diagnostics); + } +} diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts new file mode 100644 index 00000000..0898e725 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextClient.ts @@ -0,0 +1,86 @@ +import type { + CompletionContext, + CompletionResult, +} from '@codemirror/autocomplete'; +import type { Transaction } from '@codemirror/state'; + +import type { EditorStore } from '../editor/EditorStore'; +import { ContentAssistService } from './ContentAssistService'; +import { HighlightingService } from './HighlightingService'; +import { OccurrencesService } from './OccurrencesService'; +import { UpdateService } from './UpdateService'; +import { getLogger } from '../utils/logger'; +import { ValidationService } from './ValidationService'; +import { XtextWebSocketClient } from './XtextWebSocketClient'; +import { XtextWebPushService } from './xtextMessages'; + +const log = getLogger('xtext.XtextClient'); + +export class XtextClient { + private readonly webSocketClient: XtextWebSocketClient; + + private readonly updateService: UpdateService; + + private readonly contentAssistService: ContentAssistService; + + private readonly highlightingService: HighlightingService; + + private readonly validationService: ValidationService; + + private readonly occurrencesService: OccurrencesService; + + constructor(store: EditorStore) { + this.webSocketClient = new XtextWebSocketClient( + () => this.updateService.onReconnect(), + (resource, stateId, service, push) => this.onPush(resource, stateId, service, push), + ); + this.updateService = new UpdateService(store, this.webSocketClient); + this.contentAssistService = new ContentAssistService(this.updateService); + this.highlightingService = new HighlightingService(store, this.updateService); + this.validationService = new ValidationService(store, this.updateService); + this.occurrencesService = new OccurrencesService( + store, + this.webSocketClient, + this.updateService, + ); + } + + onTransaction(transaction: Transaction): void { + // `ContentAssistService.prototype.onTransaction` needs the dirty change desc + // _before_ the current edit, so we call it before `updateService`. + this.contentAssistService.onTransaction(transaction); + this.updateService.onTransaction(transaction); + this.occurrencesService.onTransaction(transaction); + } + + private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { + const { resourceName, xtextStateId } = this.updateService; + if (resource !== resourceName) { + log.error('Unknown resource name: expected:', resourceName, 'got:', resource); + return; + } + if (stateId !== xtextStateId) { + log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId); + // The current push message might be stale (referring to a previous state), + // so this is not neccessarily an error and there is no need to force-reconnect. + return; + } + switch (service) { + case 'highlight': + this.highlightingService.onPush(push); + return; + case 'validate': + this.validationService.onPush(push); + } + } + + contentAssist(context: CompletionContext): Promise { + return this.contentAssistService.contentAssist(context); + } + + formatText(): void { + this.updateService.formatText().catch((e) => { + log.error('Error while formatting text', e); + }); + } +} diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..2ce20a54 --- /dev/null +++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts @@ -0,0 +1,362 @@ +import { nanoid } from 'nanoid'; + +import { getLogger } from '../utils/logger'; +import { PendingTask } from '../utils/PendingTask'; +import { Timer } from '../utils/Timer'; +import { + xtextWebErrorResponse, + XtextWebRequest, + xtextWebOkResponse, + xtextWebPushMessage, + XtextWebPushService, +} from './xtextMessages'; +import { pongResult } from './xtextServiceResults'; + +const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; + +const WEBSOCKET_CLOSE_OK = 1000; + +const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; + +const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; + +const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +const PING_TIMEOUT_MS = 10 * 1000; + +const REQUEST_TIMEOUT_MS = 1000; + +const log = getLogger('xtext.XtextWebSocketClient'); + +export type ReconnectHandler = () => void; + +export type PushHandler = ( + resourceId: string, + stateId: string, + service: XtextWebPushService, + data: unknown, +) => void; + +enum State { + Initial, + Opening, + TabVisible, + TabHiddenIdle, + TabHiddenWaiting, + Error, + TimedOut, +} + +export class XtextWebSocketClient { + private nextMessageId = 0; + + private connection!: WebSocket; + + private readonly pendingRequests = new Map>(); + + private readonly onReconnect: ReconnectHandler; + + private readonly onPush: PushHandler; + + private state = State.Initial; + + private reconnectTryCount = 0; + + private readonly idleTimer = new Timer(() => { + this.handleIdleTimeout(); + }, BACKGROUND_IDLE_TIMEOUT_MS); + + private readonly pingTimer = new Timer(() => { + this.sendPing(); + }, PING_TIMEOUT_MS); + + private readonly reconnectTimer = new Timer(() => { + this.handleReconnect(); + }); + + constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { + this.onReconnect = onReconnect; + this.onPush = onPush; + document.addEventListener('visibilitychange', () => { + this.handleVisibilityChange(); + }); + this.reconnect(); + } + + private get isLogicallyClosed(): boolean { + return this.state === State.Error || this.state === State.TimedOut; + } + + get isOpen(): boolean { + return this.state === State.TabVisible + || this.state === State.TabHiddenIdle + || this.state === State.TabHiddenWaiting; + } + + private reconnect() { + if (this.isOpen || this.state === State.Opening) { + log.error('Trying to reconnect from', this.state); + return; + } + this.state = State.Opening; + const webSocketServer = window.origin.replace(/^http/, 'ws'); + const webSocketUrl = `${webSocketServer}/xtext-service`; + this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); + this.connection.addEventListener('open', () => { + if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { + log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); + this.forceReconnectOnError(); + } + if (document.visibilityState === 'hidden') { + this.handleTabHidden(); + } else { + this.handleTabVisibleConnected(); + } + log.info('Connected to websocket'); + this.nextMessageId = 0; + this.reconnectTryCount = 0; + this.pingTimer.schedule(); + this.onReconnect(); + }); + this.connection.addEventListener('error', (event) => { + log.error('Unexpected websocket error', event); + this.forceReconnectOnError(); + }); + this.connection.addEventListener('message', (event) => { + this.handleMessage(event.data); + }); + this.connection.addEventListener('close', (event) => { + if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK + && this.pendingRequests.size === 0) { + log.info('Websocket closed'); + return; + } + log.error('Websocket closed unexpectedly', event.code, event.reason); + this.forceReconnectOnError(); + }); + } + + private handleVisibilityChange() { + if (document.visibilityState === 'hidden') { + if (this.state === State.TabVisible) { + this.handleTabHidden(); + } + return; + } + this.idleTimer.cancel(); + if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) { + this.handleTabVisibleConnected(); + return; + } + if (this.state === State.TimedOut) { + this.reconnect(); + } + } + + private handleTabHidden() { + log.debug('Tab hidden while websocket is connected'); + this.state = State.TabHiddenIdle; + this.idleTimer.schedule(); + } + + private handleTabVisibleConnected() { + log.debug('Tab visible while websocket is connected'); + this.state = State.TabVisible; + } + + private handleIdleTimeout() { + log.trace('Waiting for pending tasks before disconnect'); + if (this.state === State.TabHiddenIdle) { + this.state = State.TabHiddenWaiting; + this.handleWaitingForDisconnect(); + } + } + + private handleWaitingForDisconnect() { + if (this.state !== State.TabHiddenWaiting) { + return; + } + const pending = this.pendingRequests.size; + if (pending === 0) { + log.info('Closing idle websocket'); + this.state = State.TimedOut; + this.closeConnection(1000, 'idle timeout'); + return; + } + log.info('Waiting for', pending, 'pending requests before closing websocket'); + } + + private sendPing() { + if (!this.isOpen) { + return; + } + const ping = nanoid(); + log.trace('Ping', ping); + this.send({ ping }).then((result) => { + const parsedPongResult = pongResult.safeParse(result); + if (parsedPongResult.success && parsedPongResult.data.pong === ping) { + log.trace('Pong', ping); + this.pingTimer.schedule(); + } else { + log.error('Invalid pong:', parsedPongResult, 'expected:', ping); + this.forceReconnectOnError(); + } + }).catch((error) => { + log.error('Error while waiting for ping', error); + this.forceReconnectOnError(); + }); + } + + send(request: unknown): Promise { + if (!this.isOpen) { + throw new Error('Not open'); + } + const messageId = this.nextMessageId.toString(16); + if (messageId in this.pendingRequests) { + log.error('Message id wraparound still pending', messageId); + this.rejectRequest(messageId, new Error('Message id wraparound')); + } + if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { + this.nextMessageId = 0; + } else { + this.nextMessageId += 1; + } + const message = JSON.stringify({ + id: messageId, + request, + } as XtextWebRequest); + log.trace('Sending message', message); + return new Promise((resolve, reject) => { + const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { + this.removePendingRequest(messageId); + }); + this.pendingRequests.set(messageId, task); + this.connection.send(message); + }); + } + + private handleMessage(messageStr: unknown) { + if (typeof messageStr !== 'string') { + log.error('Unexpected binary message', messageStr); + this.forceReconnectOnError(); + return; + } + log.trace('Incoming websocket message', messageStr); + let message: unknown; + try { + message = JSON.parse(messageStr); + } catch (error) { + log.error('Json parse error', error); + this.forceReconnectOnError(); + return; + } + const okResponse = xtextWebOkResponse.safeParse(message); + if (okResponse.success) { + const { id, response } = okResponse.data; + this.resolveRequest(id, response); + return; + } + const errorResponse = xtextWebErrorResponse.safeParse(message); + if (errorResponse.success) { + const { id, error, message: errorMessage } = errorResponse.data; + this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); + if (error === 'server') { + log.error('Reconnecting due to server error: ', errorMessage); + this.forceReconnectOnError(); + } + return; + } + const pushMessage = xtextWebPushMessage.safeParse(message); + if (pushMessage.success) { + const { + resource, + stateId, + service, + push, + } = pushMessage.data; + this.onPush(resource, stateId, service, push); + } else { + log.error( + 'Unexpected websocket message:', + message, + 'not ok response because:', + okResponse.error, + 'not error response because:', + errorResponse.error, + 'not push message because:', + pushMessage.error, + ); + this.forceReconnectOnError(); + } + } + + private resolveRequest(messageId: string, value: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + if (pendingRequest) { + pendingRequest.resolve(value); + this.removePendingRequest(messageId); + return; + } + log.error('Trying to resolve unknown request', messageId, 'with', value); + } + + private rejectRequest(messageId: string, reason?: unknown) { + const pendingRequest = this.pendingRequests.get(messageId); + if (pendingRequest) { + pendingRequest.reject(reason); + this.removePendingRequest(messageId); + return; + } + log.error('Trying to reject unknown request', messageId, 'with', reason); + } + + private removePendingRequest(messageId: string) { + this.pendingRequests.delete(messageId); + this.handleWaitingForDisconnect(); + } + + forceReconnectOnError(): void { + if (this.isLogicallyClosed) { + return; + } + this.abortPendingRequests(); + this.closeConnection(1000, 'reconnecting due to error'); + log.error('Reconnecting after delay due to error'); + this.handleErrorState(); + } + + private abortPendingRequests() { + this.pendingRequests.forEach((request) => { + request.reject(new Error('Websocket disconnect')); + }); + this.pendingRequests.clear(); + } + + private closeConnection(code: number, reason: string) { + this.pingTimer.cancel(); + const { readyState } = this.connection; + if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { + this.connection.close(code, reason); + } + } + + private handleErrorState() { + this.state = State.Error; + this.reconnectTryCount += 1; + const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS; + log.info('Reconnecting in', delay, 'ms'); + this.reconnectTimer.schedule(delay); + } + + private handleReconnect() { + if (this.state !== State.Error) { + log.error('Unexpected reconnect in', this.state); + return; + } + if (document.visibilityState === 'hidden') { + this.state = State.TimedOut; + } else { + this.reconnect(); + } + } +} diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts new file mode 100644 index 00000000..c4305fcf --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextMessages.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const xtextWebRequest = z.object({ + id: z.string().nonempty(), + request: z.unknown(), +}); + +export type XtextWebRequest = z.infer; + +export const xtextWebOkResponse = z.object({ + id: z.string().nonempty(), + response: z.unknown(), +}); + +export type XtextWebOkResponse = z.infer; + +export const xtextWebErrorKind = z.enum(['request', 'server']); + +export type XtextWebErrorKind = z.infer; + +export const xtextWebErrorResponse = z.object({ + id: z.string().nonempty(), + error: xtextWebErrorKind, + message: z.string(), +}); + +export type XtextWebErrorResponse = z.infer; + +export const xtextWebPushService = z.enum(['highlight', 'validate']); + +export type XtextWebPushService = z.infer; + +export const xtextWebPushMessage = z.object({ + resource: z.string().nonempty(), + stateId: z.string().nonempty(), + service: xtextWebPushService, + push: z.unknown(), +}); + +export type XtextWebPushMessage = z.infer; diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..f79b059c --- /dev/null +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +export const pongResult = z.object({ + pong: z.string().nonempty(), +}); + +export type PongResult = z.infer; + +export const documentStateResult = z.object({ + stateId: z.string().nonempty(), +}); + +export type DocumentStateResult = z.infer; + +export const conflict = z.enum(['invalidStateId', 'canceled']); + +export type Conflict = z.infer; + +export const serviceConflictResult = z.object({ + conflict, +}); + +export type ServiceConflictResult = z.infer; + +export function isConflictResult(result: unknown, conflictType: Conflict): boolean { + const parsedConflictResult = serviceConflictResult.safeParse(result); + return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; +} + +export const severity = z.enum(['error', 'warning', 'info', 'ignore']); + +export type Severity = z.infer; + +export const issue = z.object({ + description: z.string().nonempty(), + severity, + line: z.number().int(), + column: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), +}); + +export type Issue = z.infer; + +export const validationResult = z.object({ + issues: issue.array(), +}); + +export type ValidationResult = z.infer; + +export const replaceRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + text: z.string(), +}); + +export type ReplaceRegion = z.infer; + +export const textRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), +}); + +export type TextRegion = z.infer; + +export const contentAssistEntry = z.object({ + prefix: z.string(), + proposal: z.string().nonempty(), + label: z.string().optional(), + description: z.string().nonempty().optional(), + documentation: z.string().nonempty().optional(), + escapePosition: z.number().int().nonnegative().optional(), + textReplacements: replaceRegion.array(), + editPositions: textRegion.array(), + kind: z.string().nonempty(), +}); + +export type ContentAssistEntry = z.infer; + +export const contentAssistResult = documentStateResult.extend({ + entries: contentAssistEntry.array(), +}); + +export type ContentAssistResult = z.infer; + +export const highlightingRegion = z.object({ + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + styleClasses: z.string().nonempty().array(), +}); + +export type HighlightingRegion = z.infer; + +export const highlightingResult = z.object({ + regions: highlightingRegion.array(), +}); + +export type HighlightingResult = z.infer; + +export const occurrencesResult = documentStateResult.extend({ + writeRegions: textRegion.array(), + readRegions: textRegion.array(), +}); + +export type OccurrencesResult = z.infer; + +export const formattingResult = documentStateResult.extend({ + formattedText: z.string(), + replaceRegion: textRegion, +}); + +export type FormattingResult = z.infer; diff --git a/subprojects/frontend/tsconfig.json b/subprojects/frontend/tsconfig.json new file mode 100644 index 00000000..94c357c5 --- /dev/null +++ b/subprojects/frontend/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "exactOptionalPropertyTypes": false, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["./src/**/*"], + "exclude": ["./build/generated/sources/lezer/*"] +} diff --git a/subprojects/frontend/tsconfig.sonar.json b/subprojects/frontend/tsconfig.sonar.json new file mode 100644 index 00000000..9db12b91 --- /dev/null +++ b/subprojects/frontend/tsconfig.sonar.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react", + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["./src/**/*"] +} diff --git a/subprojects/frontend/webpack.config.js b/subprojects/frontend/webpack.config.js new file mode 100644 index 00000000..bacb7e4a --- /dev/null +++ b/subprojects/frontend/webpack.config.js @@ -0,0 +1,164 @@ +const fs = require('fs'); +const path = require('path'); + +const { DefinePlugin } = require('webpack'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const HtmlWebpackInjectPreload = require('@principalstudio/html-webpack-inject-preload'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity'); + +const packageInfo = require('./package.json'); + +const currentNodeEnv = process.env.NODE_ENV || 'development'; +const devMode = currentNodeEnv !== 'production'; +const outputPath = path.resolve(__dirname, 'build/webpack', currentNodeEnv); + +function portNumberOrElse (envName, fallback) { + const value = process.env[envName]; + return value ? parseInt(value) : fallback; +} + +const listenHost = process.env['LISTEN_HOST'] || 'localhost'; +const listenPort = portNumberOrElse('LISTEN_PORT', 1313); +const apiHost = process.env['API_HOST'] || listenHost; +const apiPort = portNumberOrElse('API_PORT', 1312); +const publicHost = process.env['PUBLIC_HOST'] || listenHost; +const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort); + +module.exports = { + mode: devMode ? 'development' : 'production', + entry: './src/index', + output: { + path: outputPath, + publicPath: '/', + filename: devMode ? '[name].js' : '[name].[contenthash].js', + assetModuleFilename: devMode ? '[name][ext]' : '[name].[contenthash][ext]', + clean: true, + crossOriginLoading: 'anonymous', + }, + module: { + rules: [ + { + test: /.[jt]sx?$/i, + include: [path.resolve(__dirname, 'src')], + use: [ + { + loader: 'babel-loader', + options: { + presets: [ + [ + '@babel/preset-env', + { + targets: 'defaults', + }, + ], + '@babel/preset-react', + [ + '@babel/preset-typescript', + { + isTSX: true, + allExtensions: true, + allowDeclareFields: true, + onlyRemoveTypeImports: true, + optimizeConstEnums: true, + }, + ] + ], + plugins: [ + '@babel/plugin-transform-runtime', + ], + }, + }, + ], + }, + { + test: /\.scss$/i, + use: [ + devMode ? 'style-loader' : MiniCssExtractPlugin.loader, + 'css-loader', + { + loader: 'sass-loader', + options: { + implementation: require.resolve('sass'), + }, + }, + ], + }, + { + test: /\.(gif|png|jpe?g|svg?)$/i, + use: [ + { + loader: 'image-webpack-loader', + options: { + disable: true, + } + }, + ], + type: 'asset', + }, + { + test: /\.woff2?$/i, + type: 'asset/resource', + }, + ], + }, + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx'], + }, + devtool: devMode ? 'inline-source-map' : 'source-map', + optimization: { + providedExports: !devMode, + sideEffects: devMode ? 'flag' : true, + splitChunks: { + chunks: 'all', + }, + }, + devServer: { + client: { + logging: 'info', + overlay: true, + progress: true, + webSocketURL: { + hostname: publicHost, + port: publicPort, + protocol: publicPort === 443 ? 'wss' : 'ws', + }, + }, + compress: true, + host: listenHost, + port: listenPort, + proxy: { + '/xtext-service': { + target: `${apiPort === 443 ? 'https' : 'http'}://${apiHost}:${apiPort}`, + ws: true, + }, + }, + }, + plugins: [ + new DefinePlugin({ + 'DEBUG': JSON.stringify(devMode), + 'PACKAGE_NAME': JSON.stringify(packageInfo.name), + 'PACKAGE_VERSION': JSON.stringify(packageInfo.version), + }), + new MiniCssExtractPlugin({ + filename: '[name].[contenthash].css', + chunkFilename: '[name].[contenthash].css', + }), + new SubresourceIntegrityPlugin(), + new HtmlWebpackPlugin({ + template: 'src/index.html', + }), + new HtmlWebpackInjectPreload({ + files: [ + { + match: /(roboto-latin-(400|500)-normal|jetbrains-mono-latin-variable).*\.woff2/, + attributes: { + as: 'font', + type: 'font/woff2', + crossorigin: 'anonymous', + }, + }, + ], + }), + ], +}; diff --git a/subprojects/language-web/.editorconfig b/subprojects/language-web/.editorconfig deleted file mode 100644 index 1b78e967..00000000 --- a/subprojects/language-web/.editorconfig +++ /dev/null @@ -1,5 +0,0 @@ -[src/main/css/xtext/**.css] -indent_style = tab - -[src/main/js/xtext/**.js] -indent_style = tab diff --git a/subprojects/language-web/.eslintrc.js b/subprojects/language-web/.eslintrc.js deleted file mode 100644 index b27feb0e..00000000 --- a/subprojects/language-web/.eslintrc.js +++ /dev/null @@ -1,40 +0,0 @@ -// Loosely based on -// https://github.com/iamturns/create-exposed-app/blob/f14e435b8ce179c89cce3eea89e56202153a53da/.eslintrc.js -module.exports = { - plugins: [ - '@typescript-eslint', - ], - extends: [ - 'airbnb', - 'airbnb-typescript', - 'airbnb/hooks', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - ], - parserOptions: { - project: './tsconfig.json', - }, - rules: { - // https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html - 'import/prefer-default-export': 'off', - 'import/no-default-export': 'error', - // propTypes are for runtime validation, but we rely on TypeScript for build-time validation: - // https://github.com/yannickcr/eslint-plugin-react/issues/2275#issuecomment-492003857 - 'react/prop-types': 'off', - // Make sure switches are exhaustive: https://stackoverflow.com/a/60166264 - 'default-case': 'off', - '@typescript-eslint/switch-exhaustiveness-check': 'error', - // https://github.com/airbnb/javascript/pull/2501 - 'react/function-component-definition': ['error', { - namedComponents: 'function-expression', - namedComponents: 'function-declaration', - }], - }, - env: { - browser: true, - }, - ignorePatterns: [ - '*.js', - 'build/**/*', - ], -}; diff --git a/subprojects/language-web/.stylelintrc.js b/subprojects/language-web/.stylelintrc.js deleted file mode 100644 index 7adf8f26..00000000 --- a/subprojects/language-web/.stylelintrc.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - extends: 'stylelint-config-recommended-scss', - // Simplified for only :export to TypeScript based on - // https://github.com/pascalduez/stylelint-config-css-modules/blob/d792a6ac7d2bce8239edccbc5a72e0616f22d696/index.js - rules: { - 'selector-pseudo-class-no-unknown': [ - true, - { - ignorePseudoClasses: [ - 'export', - ], - }, - ], - }, -}; diff --git a/subprojects/language-web/build.gradle b/subprojects/language-web/build.gradle index a549288a..6be4d5e8 100644 --- a/subprojects/language-web/build.gradle +++ b/subprojects/language-web/build.gradle @@ -1,10 +1,14 @@ plugins { - id 'refinery-frontend-workspace' id 'refinery-java-application' id 'refinery-xtext-conventions' } -import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn +configurations { + webapp { + canBeConsumed = false + canBeResolved = true + } +} dependencies { implementation project(':refinery-language') @@ -16,6 +20,7 @@ dependencies { implementation libs.jetty.websocket.server implementation libs.slf4j.simple implementation libs.slf4j.log4j + webapp project(path: ':refinery-frontend', configuration: 'productionAssets') testImplementation testFixtures(project(':refinery-language')) testImplementation libs.jetty.websocket.client } @@ -28,120 +33,36 @@ for (taskName in ['compileJava', 'processResources']) { } } -def webpackOutputDir = "${buildDir}/webpack" -def productionResources = "${webpackOutputDir}/production" -def serverMainClass = 'tools.refinery.language.web.ServerLauncher' - -frontend { - assembleScript = 'assemble:webpack' -} - -def installFrontend = tasks.named('installFrontend') - -def generateLezerGrammar = tasks.register('generateLezerGrammar', RunYarn) { - dependsOn installFrontend - inputs.file('src/main/js/language/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 generateLezerGrammar - inputs.dir 'src/main/css' - inputs.dir 'src/main/html' - inputs.dir 'src/main/js' - inputs.file "${buildDir}/generated/sources/lezer/problem.ts" - inputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts" - inputs.files('package.json', 'yarn.lock', 'webpack.config.js') - outputs.dir productionResources -} - -def eslint = tasks.register('eslint', RunYarn) { - dependsOn installFrontend - inputs.dir 'src/main/js' - inputs.files('.eslintrc.js', 'tsconfig.json') - if (project.hasProperty('ci')) { - outputs.file "${buildDir}/eslint.json" - script = 'run check:eslint:ci' - } else { - script = 'run check:eslint' - } - group = 'verification' - description = 'Check for TypeScript errors.' -} - -def stylelint = tasks.register('stylelint', RunYarn) { - dependsOn installFrontend - inputs.dir 'src/main/css' - inputs.file '.stylelintrc.js' - if (project.hasProperty('ci')) { - outputs.file "${buildDir}/stylelint.json" - script = 'run check:stylelint:ci' - } else { - script = 'run check:stylelint' - } - group = 'verification' - description = 'Check for Sass errors.' -} - -tasks.named('check') { - dependsOn(eslint, stylelint) -} - -mainClassName = serverMainClass +mainClassName = 'tools.refinery.language.web.ServerLauncher' tasks.named('jar') { - dependsOn assembleFrontend - from(productionResources) { + dependsOn project.configurations.webapp + from(project.configurations.webapp) { into 'webapp' } } tasks.named('shadowJar') { - dependsOn assembleFrontend - from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output) + dependsOn project.configurations.webapp + from(project.sourceSets.main.output) configurations = [project.configurations.runtimeClasspath] exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA','schema/*', '.options', '.api_description', '*.profile', 'about.*', 'about_*.html', 'about_files/*', 'plugin.xml', 'systembundle.properties', 'profile.list', 'META-INF/resources/xtext/**') append('plugin.properties') - from(productionResources) { + from(project.configurations.webapp) { into 'webapp' } } def jettyRun = tasks.register('jettyRun', JavaExec) { - dependsOn assembleFrontend + dependsOn project.configurations.webapp dependsOn sourceSets.main.runtimeClasspath - classpath = sourceSets.main.runtimeClasspath.filter{it.exists()} - mainClass = serverMainClass + classpath = sourceSets.main.runtimeClasspath + mainClass = mainClassName standardInput = System.in - environment BASE_RESOURCE: productionResources + def baseResource = project.configurations.webapp.incoming.artifacts.artifactFiles.first() + environment BASE_RESOURCE: baseResource group = 'run' description = 'Start a Jetty web server serving the Xtex API and assets.' } - -tasks.register('webpackServe', RunYarn) { - dependsOn installFrontend - dependsOn generateLezerGrammar - outputs.dir "${webpackOutputDir}/development" - script = 'run serve' - group = 'run' - description = 'Start a Webpack dev server with hot module replacement.' -} - -sonarqube.properties { - properties['sonar.sources'] += [ - 'src/main/css', - 'src/main/html', - 'src/main/js', - ] - property 'sonar.nodejs.executable', "${frontend.nodeInstallDirectory.get()}/bin/node" - property 'sonar.eslint.reportPaths', "${buildDir}/eslint.json" - property 'sonar.css.stylelint.reportPaths', "${buildDir}/stylelint.json" - // SonarJS does not pick up typescript files with `exactOptionalPropertyTypes` - property 'sonar.typescript.tsconfigPath', 'tsconfig.sonar.json' -} diff --git a/subprojects/language-web/package.json b/subprojects/language-web/package.json deleted file mode 100644 index 5fa977d9..00000000 --- a/subprojects/language-web/package.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "@refinery/language-web", - "version": "0.0.0", - "description": "Web frontend for VIATRA-Generator", - "main": "index.js", - "scripts": { - "assemble:lezer": "lezer-generator src/main/js/language/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 .", - "check:eslint:ci": "eslint -f json -o build/eslint.json .", - "check:stylelint": "stylelint src/main/css/**/*.scss", - "check:stylelint:ci": "stylelint -f json src/main/css/**/*.scss > build/stylelint.json" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/graphs4value/refinery.git" - }, - "author": "VIATRA-Generator authors", - "license": "EPL-2.0", - "bugs": { - "url": "https://github.com/graphs4value/issues" - }, - "homepage": "https://refinery.tools", - "devDependencies": { - "@babel/core": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.4", - "@babel/preset-env": "^7.16.4", - "@babel/preset-react": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", - "@lezer/generator": "^0.15.2", - "@principalstudio/html-webpack-inject-preload": "^1.2.7", - "@types/react": "^17.0.37", - "@types/react-dom": "^17.0.11", - "@typescript-eslint/eslint-plugin": "^5.6.0", - "@typescript-eslint/parser": "^5.6.0", - "babel-loader": "^8.2.3", - "css-loader": "^6.5.1", - "eslint": "^8.4.1", - "eslint-config-airbnb": "^19.0.2", - "eslint-config-airbnb-typescript": "^16.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.27.1", - "eslint-plugin-react-hooks": "^4.3.0", - "html-webpack-plugin": "^5.5.0", - "image-webpack-loader": "^8.0.1", - "magic-comments-loader": "^1.4.1", - "mini-css-extract-plugin": "^2.4.5", - "postcss": "^8.4.4", - "postcss-scss": "^4.0.2", - "sass": "^1.45.0", - "sass-loader": "^12.4.0", - "style-loader": "^3.3.1", - "stylelint": "^14.1.0", - "stylelint-config-recommended-scss": "^5.0.2", - "stylelint-scss": "^4.0.1", - "typescript": "~4.5.3", - "webpack": "^5.65.0", - "webpack-cli": "^4.9.1", - "webpack-dev-server": "^4.6.0", - "webpack-subresource-integrity": "^5.0.0" - }, - "dependencies": { - "@babel/runtime": "^7.16.3", - "@codemirror/autocomplete": "^0.19.9", - "@codemirror/closebrackets": "^0.19.0", - "@codemirror/commands": "^0.19.6", - "@codemirror/comment": "^0.19.0", - "@codemirror/fold": "^0.19.2", - "@codemirror/gutter": "^0.19.9", - "@codemirror/highlight": "^0.19.6", - "@codemirror/history": "^0.19.0", - "@codemirror/language": "^0.19.7", - "@codemirror/lint": "^0.19.3", - "@codemirror/matchbrackets": "^0.19.3", - "@codemirror/rangeset": "^0.19.2", - "@codemirror/rectangular-selection": "^0.19.1", - "@codemirror/search": "^0.19.4", - "@codemirror/state": "^0.19.6", - "@codemirror/view": "^0.19.29", - "@emotion/react": "^11.7.0", - "@emotion/styled": "^11.6.0", - "@fontsource/jetbrains-mono": "^4.5.0", - "@fontsource/roboto": "^4.5.1", - "@lezer/common": "^0.15.10", - "@lezer/lr": "^0.15.5", - "@mui/icons-material": "5.2.1", - "@mui/material": "5.2.3", - "ansi-styles": "^6.1.0", - "escape-string-regexp": "^5.0.0", - "loglevel": "^1.8.0", - "loglevel-plugin-prefix": "^0.8.4", - "mobx": "^6.3.8", - "mobx-react-lite": "^3.2.2", - "nanoid": "^3.1.30", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "zod": "^3.11.6" - } -} diff --git a/subprojects/language-web/src/main/css/index.scss b/subprojects/language-web/src/main/css/index.scss deleted file mode 100644 index ad876aaf..00000000 --- a/subprojects/language-web/src/main/css/index.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use '@fontsource/roboto/scss/mixins' as Roboto; -@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono; - -$fontWeights: 300, 400, 500, 700; -@each $weight in $fontWeights { - @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight); - @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight, $style: italic); -} - -$monoFontWeights: 400, 700; -@each $weight in $monoFontWeights { - @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight); - @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight, $style: italic); -} -@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable'); -@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic); diff --git a/subprojects/language-web/src/main/css/themeVariables.module.scss b/subprojects/language-web/src/main/css/themeVariables.module.scss deleted file mode 100644 index 85af4219..00000000 --- a/subprojects/language-web/src/main/css/themeVariables.module.scss +++ /dev/null @@ -1,9 +0,0 @@ -@import './themes'; - -:export { - @each $themeName, $theme in $themes { - @each $variable, $value in $theme { - #{$themeName}--#{$variable}: $value, - } - } -} diff --git a/subprojects/language-web/src/main/css/themes.scss b/subprojects/language-web/src/main/css/themes.scss deleted file mode 100644 index a30f1de3..00000000 --- a/subprojects/language-web/src/main/css/themes.scss +++ /dev/null @@ -1,38 +0,0 @@ -$themes: ( - 'dark': ( - 'foreground': #abb2bf, - 'foregroundHighlight': #eeffff, - 'background': #212121, - 'primary': #56b6c2, - 'secondary': #ff5370, - 'keyword': #56b6c2, - 'predicate': #d6e9ff, - 'variable': #c8ae9d, - 'uniqueNode': #d6e9ff, - 'number': #6e88a6, - 'delimiter': #707787, - 'comment': #5c6370, - 'cursor': #56b6c2, - 'selection': #3e4452, - 'currentLine': rgba(0, 0, 0, 0.2), - 'lineNumber': #5c6370, - ), - 'light': ( - 'foreground': #abb2bf, - 'background': #282c34, - 'paper': #21252b, - 'primary': #56b6c2, - 'secondary': #ff5370, - 'keyword': #56b6c2, - 'predicate': #d6e9ff, - 'variable': #c8ae9d, - 'uniqueNode': #d6e9ff, - 'number': #6e88a6, - 'delimiter': #56606d, - 'comment': #55606d, - 'cursor': #f3efe7, - 'selection': #3e4452, - 'currentLine': #2c323c, - 'lineNumber': #5c6370, - ), -); diff --git a/subprojects/language-web/src/main/html/index.html b/subprojects/language-web/src/main/html/index.html deleted file mode 100644 index f404aa8a..00000000 --- a/subprojects/language-web/src/main/html/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - Refinery - - - -
- - diff --git a/subprojects/language-web/src/main/js/App.tsx b/subprojects/language-web/src/main/js/App.tsx deleted file mode 100644 index 54f92f9a..00000000 --- a/subprojects/language-web/src/main/js/App.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import AppBar from '@mui/material/AppBar'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import MenuIcon from '@mui/icons-material/Menu'; -import React from 'react'; - -import { EditorArea } from './editor/EditorArea'; -import { EditorButtons } from './editor/EditorButtons'; -import { GenerateButton } from './editor/GenerateButton'; - -export function App(): JSX.Element { - return ( - - - - - - - - Refinery - - - - - - - - - - - - ); -} diff --git a/subprojects/language-web/src/main/js/RootStore.tsx b/subprojects/language-web/src/main/js/RootStore.tsx deleted file mode 100644 index baf0b61e..00000000 --- a/subprojects/language-web/src/main/js/RootStore.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { createContext, useContext } from 'react'; - -import { EditorStore } from './editor/EditorStore'; -import { ThemeStore } from './theme/ThemeStore'; - -export class RootStore { - editorStore; - - themeStore; - - constructor(initialValue: string) { - this.themeStore = new ThemeStore(); - this.editorStore = new EditorStore(initialValue, this.themeStore); - } -} - -const StoreContext = createContext(undefined); - -export interface RootStoreProviderProps { - children: JSX.Element; - - rootStore: RootStore; -} - -export function RootStoreProvider({ children, rootStore }: RootStoreProviderProps): JSX.Element { - return ( - - {children} - - ); -} - -export const useRootStore = (): RootStore => { - const rootStore = useContext(StoreContext); - if (!rootStore) { - throw new Error('useRootStore must be used within RootStoreProvider'); - } - return rootStore; -}; diff --git a/subprojects/language-web/src/main/js/editor/EditorArea.tsx b/subprojects/language-web/src/main/js/editor/EditorArea.tsx deleted file mode 100644 index dba20f6e..00000000 --- a/subprojects/language-web/src/main/js/editor/EditorArea.tsx +++ /dev/null @@ -1,152 +0,0 @@ -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, { - useCallback, - useEffect, - useRef, - useState, -} from 'react'; - -import { EditorParent } from './EditorParent'; -import { useRootStore } from '../RootStore'; -import { getLogger } from '../utils/logger'; - -const log = getLogger('editor.EditorArea'); - -function usePanel( - 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; - } - 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, - panelId, - openCommand, - closeCommand, - closeCallback, - ]); - return setCachedViewState; -} - -function fixCodeMirrorAccessibility(editorView: EditorView) { - // Reported by Lighthouse 8.3.0. - const { contentDOM } = editorView; - contentDOM.removeAttribute('aria-expanded'); - contentDOM.setAttribute('aria-label', 'Code editor'); -} - -export const EditorArea = observer(() => { - const { editorStore } = useRootStore(); - const editorParentRef = useRef(null); - const [editorViewState, setEditorViewState] = useState(null); - - const setSearchPanelOpen = usePanel( - 'search', - editorStore.showSearchPanel, - editorViewState, - openSearchPanel, - closeSearchPanel, - useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]), - ); - - const setLintPanelOpen = usePanel( - 'panel-lint', - editorStore.showLintPanel, - editorViewState, - openLintPanel, - closeLintPanel, - useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]), - ); - - useEffect(() => { - if (editorParentRef.current === null) { - return () => { - // Nothing to clean up. - }; - } - - const editorView = new EditorView({ - state: editorStore.state, - parent: editorParentRef.current, - dispatch: (transaction) => { - editorStore.onTransaction(transaction); - editorView.update([transaction]); - if (editorView.state !== editorStore.state) { - log.error( - 'Failed to synchronize editor state - store state:', - editorStore.state, - 'view state:', - editorView.state, - ); - } - }, - }); - fixCodeMirrorAccessibility(editorView); - setEditorViewState(editorView); - setSearchPanelOpen(false); - setLintPanelOpen(false); - // `dispatch` is bound to the view instance, - // so it does not have to be called as a method. - // eslint-disable-next-line @typescript-eslint/unbound-method - editorStore.updateDispatcher(editorView.dispatch); - log.info('Editor created'); - - return () => { - editorStore.updateDispatcher(null); - editorView.destroy(); - log.info('Editor destroyed'); - }; - }, [ - editorParentRef, - editorStore, - setSearchPanelOpen, - setLintPanelOpen, - ]); - - return ( - - ); -}); diff --git a/subprojects/language-web/src/main/js/editor/EditorButtons.tsx b/subprojects/language-web/src/main/js/editor/EditorButtons.tsx deleted file mode 100644 index 150aa00d..00000000 --- a/subprojects/language-web/src/main/js/editor/EditorButtons.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { Diagnostic } from '@codemirror/lint'; -import { observer } from 'mobx-react-lite'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import CheckIcon from '@mui/icons-material/Check'; -import ErrorIcon from '@mui/icons-material/Error'; -import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; -import FormatPaint from '@mui/icons-material/FormatPaint'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import RedoIcon from '@mui/icons-material/Redo'; -import SearchIcon from '@mui/icons-material/Search'; -import UndoIcon from '@mui/icons-material/Undo'; -import WarningIcon from '@mui/icons-material/Warning'; -import React from 'react'; - -import { useRootStore } from '../RootStore'; - -// Exhastive switch as proven by TypeScript. -// eslint-disable-next-line consistent-return -function getLintIcon(severity: Diagnostic['severity'] | null) { - switch (severity) { - case 'error': - return ; - case 'warning': - return ; - case 'info': - return ; - case null: - return ; - } -} - -export const EditorButtons = observer(() => { - const { editorStore } = useRootStore(); - - return ( - - - editorStore.undo()} - aria-label="Undo" - > - - - editorStore.redo()} - aria-label="Redo" - > - - - - - editorStore.toggleLineNumbers()} - aria-label="Show line numbers" - value="show-line-numbers" - > - - - editorStore.toggleSearchPanel()} - aria-label="Show find/replace" - value="show-search-panel" - > - - - editorStore.toggleLintPanel()} - aria-label="Show diagnostics panel" - value="show-lint-panel" - > - {getLintIcon(editorStore.highestDiagnosticLevel)} - - - editorStore.formatText()} - aria-label="Automatic format" - > - - - - ); -}); diff --git a/subprojects/language-web/src/main/js/editor/EditorParent.ts b/subprojects/language-web/src/main/js/editor/EditorParent.ts deleted file mode 100644 index 94ca24ea..00000000 --- a/subprojects/language-web/src/main/js/editor/EditorParent.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { styled } from '@mui/material/styles'; - -/** - * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64. - * - * Based on - * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501 - * - * @param color the color of the underline - * @returns the CSS `url()` - */ -function underline(color: string) { - const svg = ` - - `; - const svgBase64 = window.btoa(svg); - return `url('data:image/svg+xml;base64,${svgBase64}')`; -} - -export const EditorParent = styled('div')(({ theme }) => { - const codeMirrorLintStyle: Record = {}; - (['error', 'warning', 'info'] as const).forEach((severity) => { - const color = theme.palette[severity].main; - codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = { - borderLeftColor: color, - }; - codeMirrorLintStyle[`.cm-lintRange-${severity}`] = { - backgroundImage: underline(color), - }; - }); - - return { - background: theme.palette.background.default, - '&, .cm-editor': { - height: '100%', - }, - '.cm-content': { - padding: 0, - }, - '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { - fontSize: 16, - fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', - fontFeatureSettings: '"liga", "calt"', - fontWeight: 400, - letterSpacing: 0, - textRendering: 'optimizeLegibility', - }, - '.cm-scroller': { - color: theme.palette.text.secondary, - }, - '.cm-gutters': { - background: 'rgba(255, 255, 255, 0.1)', - color: theme.palette.text.disabled, - border: 'none', - }, - '.cm-specialChar': { - color: theme.palette.secondary.main, - }, - '.cm-activeLine': { - background: 'rgba(0, 0, 0, 0.3)', - }, - '.cm-activeLineGutter': { - background: 'transparent', - }, - '.cm-lineNumbers .cm-activeLineGutter': { - color: theme.palette.text.primary, - }, - '.cm-cursor, .cm-cursor-primary': { - borderColor: theme.palette.primary.main, - background: theme.palette.common.black, - }, - '.cm-selectionBackground': { - background: '#3e4453', - }, - '.cm-focused': { - outline: 'none', - '.cm-selectionBackground': { - background: '#3e4453', - }, - }, - '.cm-panels-top': { - color: theme.palette.text.secondary, - }, - '.cm-panel': { - '&, & button, & input': { - fontFamily: '"Roboto","Helvetica","Arial",sans-serif', - }, - background: theme.palette.background.paper, - borderTop: `1px solid ${theme.palette.divider}`, - 'button[name="close"]': { - background: 'transparent', - color: theme.palette.text.secondary, - cursor: 'pointer', - }, - }, - '.cm-panel.cm-panel-lint': { - 'button[name="close"]': { - // Close button interferes with scrollbar, so we better hide it. - // The panel can still be closed from the toolbar. - display: 'none', - }, - ul: { - li: { - borderBottom: `1px solid ${theme.palette.divider}`, - cursor: 'pointer', - }, - '[aria-selected]': { - background: '#3e4453', - color: theme.palette.text.primary, - }, - '&:focus [aria-selected]': { - background: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - }, - }, - }, - '.cm-foldPlaceholder': { - background: theme.palette.background.paper, - borderColor: theme.palette.text.disabled, - color: theme.palette.text.secondary, - }, - '.cmt-comment': { - fontStyle: 'italic', - color: theme.palette.text.disabled, - }, - '.cmt-number': { - color: '#6188a6', - }, - '.cmt-string': { - color: theme.palette.secondary.dark, - }, - '.cmt-keyword': { - color: theme.palette.primary.main, - }, - '.cmt-typeName, .cmt-macroName, .cmt-atom': { - color: theme.palette.text.primary, - }, - '.cmt-variableName': { - color: '#c8ae9d', - }, - '.cmt-problem-node': { - '&, & .cmt-variableName': { - color: theme.palette.text.secondary, - }, - }, - '.cmt-problem-individual': { - '&, & .cmt-variableName': { - color: theme.palette.text.primary, - }, - }, - '.cmt-problem-abstract, .cmt-problem-new': { - fontStyle: 'italic', - }, - '.cmt-problem-containment': { - fontWeight: 700, - }, - '.cmt-problem-error': { - '&, & .cmt-typeName': { - color: theme.palette.error.main, - }, - }, - '.cmt-problem-builtin': { - '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': { - color: theme.palette.primary.main, - fontWeight: 400, - fontStyle: 'normal', - }, - }, - '.cm-tooltip-autocomplete': { - background: theme.palette.background.paper, - boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%), - 0px 4px 5px 0px rgb(0 0 0 / 14%), - 0px 1px 10px 0px rgb(0 0 0 / 12%)`, - '.cm-completionIcon': { - color: theme.palette.text.secondary, - }, - '.cm-completionLabel': { - color: theme.palette.text.primary, - }, - '.cm-completionDetail': { - color: theme.palette.text.secondary, - fontStyle: 'normal', - }, - '[aria-selected]': { - background: `${theme.palette.primary.main} !important`, - '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': { - color: theme.palette.primary.contrastText, - }, - }, - }, - '.cm-completionIcon': { - width: 16, - padding: 0, - marginRight: '0.5em', - textAlign: 'center', - }, - ...codeMirrorLintStyle, - '.cm-problem-write': { - background: 'rgba(255, 255, 128, 0.3)', - }, - '.cm-problem-read': { - background: 'rgba(255, 255, 255, 0.15)', - }, - }; -}); diff --git a/subprojects/language-web/src/main/js/editor/EditorStore.ts b/subprojects/language-web/src/main/js/editor/EditorStore.ts deleted file mode 100644 index 5760de28..00000000 --- a/subprojects/language-web/src/main/js/editor/EditorStore.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; -import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; -import { defaultKeymap, indentWithTab } from '@codemirror/commands'; -import { commentKeymap } from '@codemirror/comment'; -import { foldGutter, foldKeymap } from '@codemirror/fold'; -import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; -import { classHighlightStyle } from '@codemirror/highlight'; -import { - history, - historyKeymap, - redo, - redoDepth, - undo, - undoDepth, -} from '@codemirror/history'; -import { indentOnInput } from '@codemirror/language'; -import { - Diagnostic, - lintKeymap, - setDiagnostics, -} from '@codemirror/lint'; -import { bracketMatching } from '@codemirror/matchbrackets'; -import { rectangularSelection } from '@codemirror/rectangular-selection'; -import { searchConfig, searchKeymap } from '@codemirror/search'; -import { - EditorState, - StateCommand, - StateEffect, - Transaction, - TransactionSpec, -} from '@codemirror/state'; -import { - drawSelection, - EditorView, - highlightActiveLine, - highlightSpecialChars, - keymap, -} from '@codemirror/view'; -import { - makeAutoObservable, - observable, - reaction, -} from 'mobx'; - -import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; -import { problemLanguageSupport } from '../language/problemLanguageSupport'; -import { - IHighlightRange, - semanticHighlighting, - setSemanticHighlighting, -} from './semanticHighlighting'; -import type { ThemeStore } from '../theme/ThemeStore'; -import { getLogger } from '../utils/logger'; -import { XtextClient } from '../xtext/XtextClient'; - -const log = getLogger('editor.EditorStore'); - -export class EditorStore { - private readonly themeStore; - - state: EditorState; - - private readonly client: XtextClient; - - showLineNumbers = false; - - showSearchPanel = false; - - showLintPanel = false; - - errorCount = 0; - - warningCount = 0; - - infoCount = 0; - - private readonly defaultDispatcher = (tr: Transaction): void => { - this.onTransaction(tr); - }; - - private dispatcher = this.defaultDispatcher; - - constructor(initialValue: string, themeStore: ThemeStore) { - this.themeStore = themeStore; - this.state = EditorState.create({ - doc: initialValue, - extensions: [ - autocompletion({ - activateOnTyping: true, - override: [ - (context) => this.client.contentAssist(context), - ], - }), - classHighlightStyle.extension, - closeBrackets(), - bracketMatching(), - drawSelection(), - EditorState.allowMultipleSelections.of(true), - EditorView.theme({}, { - dark: this.themeStore.darkMode, - }), - findOccurrences, - highlightActiveLine(), - highlightActiveLineGutter(), - highlightSpecialChars(), - history(), - indentOnInput(), - rectangularSelection(), - searchConfig({ - top: true, - matchCase: true, - }), - semanticHighlighting, - // We add the gutters to `extensions` in the order we want them to appear. - lineNumbers(), - foldGutter(), - keymap.of([ - { key: 'Mod-Shift-f', run: () => this.formatText() }, - ...closeBracketsKeymap, - ...commentKeymap, - ...completionKeymap, - ...foldKeymap, - ...historyKeymap, - indentWithTab, - // Override keys in `lintKeymap` to go through the `EditorStore`. - { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) }, - ...lintKeymap, - // Override keys in `searchKeymap` to go through the `EditorStore`. - { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' }, - { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' }, - ...searchKeymap, - ...defaultKeymap, - ]), - problemLanguageSupport(), - ], - }); - this.client = new XtextClient(this); - reaction( - () => this.themeStore.darkMode, - (darkMode) => { - log.debug('Update editor dark mode', darkMode); - this.dispatch({ - effects: [ - StateEffect.appendConfig.of(EditorView.theme({}, { - dark: darkMode, - })), - ], - }); - }, - ); - makeAutoObservable(this, { - state: observable.ref, - }); - } - - updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void { - this.dispatcher = newDispatcher || this.defaultDispatcher; - } - - onTransaction(tr: Transaction): void { - log.trace('Editor transaction', tr); - this.state = tr.state; - this.client.onTransaction(tr); - } - - dispatch(...specs: readonly TransactionSpec[]): void { - this.dispatcher(this.state.update(...specs)); - } - - doStateCommand(command: StateCommand): boolean { - return command({ - state: this.state, - dispatch: this.dispatcher, - }); - } - - updateDiagnostics(diagnostics: Diagnostic[]): void { - this.dispatch(setDiagnostics(this.state, diagnostics)); - this.errorCount = 0; - this.warningCount = 0; - this.infoCount = 0; - diagnostics.forEach(({ severity }) => { - switch (severity) { - case 'error': - this.errorCount += 1; - break; - case 'warning': - this.warningCount += 1; - break; - case 'info': - this.infoCount += 1; - break; - } - }); - } - - get highestDiagnosticLevel(): Diagnostic['severity'] | null { - if (this.errorCount > 0) { - return 'error'; - } - if (this.warningCount > 0) { - return 'warning'; - } - if (this.infoCount > 0) { - return 'info'; - } - return null; - } - - updateSemanticHighlighting(ranges: IHighlightRange[]): void { - this.dispatch(setSemanticHighlighting(ranges)); - } - - updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void { - this.dispatch(setOccurrences(write, read)); - } - - /** - * @returns `true` if there is history to undo - */ - get canUndo(): boolean { - return undoDepth(this.state) > 0; - } - - // eslint-disable-next-line class-methods-use-this - undo(): void { - log.debug('Undo', this.doStateCommand(undo)); - } - - /** - * @returns `true` if there is history to redo - */ - get canRedo(): boolean { - return redoDepth(this.state) > 0; - } - - // eslint-disable-next-line class-methods-use-this - redo(): void { - log.debug('Redo', this.doStateCommand(redo)); - } - - toggleLineNumbers(): void { - this.showLineNumbers = !this.showLineNumbers; - log.debug('Show line numbers', this.showLineNumbers); - } - - /** - * Sets whether the CodeMirror search panel should be open. - * - * This method can be used as a CodeMirror command, - * because it returns `false` if it didn't execute, - * allowing other commands for the same keybind to run instead. - * This matches the behavior of the `openSearchPanel` and `closeSearchPanel` - * commands from `'@codemirror/search'`. - * - * @param newShosSearchPanel whether we should show the search panel - * @returns `true` if the state was changed, `false` otherwise - */ - setSearchPanelOpen(newShowSearchPanel: boolean): boolean { - if (this.showSearchPanel === newShowSearchPanel) { - return false; - } - this.showSearchPanel = newShowSearchPanel; - log.debug('Show search panel', this.showSearchPanel); - return true; - } - - toggleSearchPanel(): void { - this.setSearchPanelOpen(!this.showSearchPanel); - } - - setLintPanelOpen(newShowLintPanel: boolean): boolean { - if (this.showLintPanel === newShowLintPanel) { - return false; - } - this.showLintPanel = newShowLintPanel; - log.debug('Show lint panel', this.showLintPanel); - return true; - } - - toggleLintPanel(): void { - this.setLintPanelOpen(!this.showLintPanel); - } - - formatText(): boolean { - this.client.formatText(); - return true; - } -} diff --git a/subprojects/language-web/src/main/js/editor/GenerateButton.tsx b/subprojects/language-web/src/main/js/editor/GenerateButton.tsx deleted file mode 100644 index 3834cec4..00000000 --- a/subprojects/language-web/src/main/js/editor/GenerateButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import Button from '@mui/material/Button'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import React from 'react'; - -import { useRootStore } from '../RootStore'; - -const GENERATE_LABEL = 'Generate'; - -export const GenerateButton = observer(() => { - const { editorStore } = useRootStore(); - const { errorCount, warningCount } = editorStore; - - const diagnostics: string[] = []; - if (errorCount > 0) { - diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`); - } - if (warningCount > 0) { - diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`); - } - const summary = diagnostics.join(' and '); - - if (errorCount > 0) { - return ( - - ); - } - - return ( - - ); -}); diff --git a/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts b/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts deleted file mode 100644 index 2d630c20..00000000 --- a/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; -import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; - -export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; - -export function decorationSetExtension(): [TransactionSpecFactory, StateField] { - const setEffect = StateEffect.define(); - const field = StateField.define({ - create() { - return Decoration.none; - }, - update(currentDecorations, transaction) { - let newDecorations: DecorationSet | null = null; - transaction.effects.forEach((effect) => { - if (effect.is(setEffect)) { - newDecorations = effect.value; - } - }); - if (newDecorations === null) { - if (transaction.docChanged) { - return currentDecorations.map(transaction.changes); - } - return currentDecorations; - } - return newDecorations; - }, - provide: (f) => EditorView.decorations.from(f), - }); - - function transactionSpecFactory(decorations: DecorationSet) { - return { - effects: [ - setEffect.of(decorations), - ], - }; - } - - return [transactionSpecFactory, field]; -} diff --git a/subprojects/language-web/src/main/js/editor/findOccurrences.ts b/subprojects/language-web/src/main/js/editor/findOccurrences.ts deleted file mode 100644 index 92102746..00000000 --- a/subprojects/language-web/src/main/js/editor/findOccurrences.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Range, RangeSet } from '@codemirror/rangeset'; -import type { TransactionSpec } from '@codemirror/state'; -import { Decoration } from '@codemirror/view'; - -import { decorationSetExtension } from './decorationSetExtension'; - -export interface IOccurrence { - from: number; - - to: number; -} - -const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); - -const writeDecoration = Decoration.mark({ - class: 'cm-problem-write', -}); - -const readDecoration = Decoration.mark({ - class: 'cm-problem-read', -}); - -export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec { - const decorations: Range[] = []; - write.forEach(({ from, to }) => { - decorations.push(writeDecoration.range(from, to)); - }); - read.forEach(({ from, to }) => { - decorations.push(readDecoration.range(from, to)); - }); - const rangeSet = RangeSet.of(decorations, true); - return setOccurrencesInteral(rangeSet); -} - -export { findOccurrences }; diff --git a/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts b/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts deleted file mode 100644 index 2aed421b..00000000 --- a/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { RangeSet } from '@codemirror/rangeset'; -import type { TransactionSpec } from '@codemirror/state'; -import { Decoration } from '@codemirror/view'; - -import { decorationSetExtension } from './decorationSetExtension'; - -export interface IHighlightRange { - from: number; - - to: number; - - classes: string[]; -} - -const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); - -export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec { - const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({ - class: classes.map((c) => `cmt-problem-${c}`).join(' '), - }).range(from, to)), true); - return setSemanticHighlightingInternal(rangeSet); -} - -export { semanticHighlighting }; diff --git a/subprojects/language-web/src/main/js/global.d.ts b/subprojects/language-web/src/main/js/global.d.ts deleted file mode 100644 index 0533a46e..00000000 --- a/subprojects/language-web/src/main/js/global.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare const DEBUG: boolean; - -declare const PACKAGE_NAME: string; - -declare const PACKAGE_VERSION: string; - -declare module '*.module.scss' { - const cssVariables: { [key in string]?: string }; - // eslint-disable-next-line import/no-default-export - export default cssVariables; -} diff --git a/subprojects/language-web/src/main/js/index.tsx b/subprojects/language-web/src/main/js/index.tsx deleted file mode 100644 index d368c9ba..00000000 --- a/subprojects/language-web/src/main/js/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { render } from 'react-dom'; -import CssBaseline from '@mui/material/CssBaseline'; - -import { App } from './App'; -import { RootStore, RootStoreProvider } from './RootStore'; -import { ThemeProvider } from './theme/ThemeProvider'; - -import '../css/index.scss'; - -const initialValue = `class Family { - contains Person[] members -} - -class Person { - Person[] children opposite parent - Person[0..1] parent opposite children - int age - TaxStatus taxStatus -} - -enum TaxStatus { - child, student, adult, retired -} - -% A child cannot have any dependents. -pred invalidTaxStatus(Person p) <-> - taxStatus(p, child), - children(p, _q) - ; taxStatus(p, retired), - parent(p, q), - !taxStatus(q, retired). - -direct rule createChild(p): - children(p, newPerson) = unknown, - equals(newPerson, newPerson) = unknown - ~> new q, - children(p, q) = true, - taxStatus(q, child) = true. - -indiv family. -Family(family). -members(family, anne). -members(family, bob). -members(family, ciri). -children(anne, ciri). -?children(bob, ciri). -default children(ciri, *): false. -taxStatus(anne, adult). -age(anne, 35). -bobAge: 27. -age(bob, bobAge). -!age(ciri, bobAge). - -scope Family = 1, Person += 5..10. -`; - -const rootStore = new RootStore(initialValue); - -const app = ( - - - - - - -); - -render(app, document.getElementById('app')); diff --git a/subprojects/language-web/src/main/js/language/folding.ts b/subprojects/language-web/src/main/js/language/folding.ts deleted file mode 100644 index 5d51f796..00000000 --- a/subprojects/language-web/src/main/js/language/folding.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 - */ -function foldWithSibling(node: SyntaxNode): FoldRange | null { - const { parent } = node; - if (parent === null) { - return null; - } - const { firstChild } = parent; - if (firstChild === null) { - return null; - } - const { cursor } = firstChild; - let nSiblings = 0; - while (cursor.nextSibling()) { - if (cursor.type === node.type) { - nSiblings += 1; - } - if (nSiblings >= 2) { - return { - from: node.from, - to: node.to, - }; - } - } - return null; -} - -export function foldWholeNode(node: SyntaxNode): FoldRange { - return { - from: node.from, - to: node.to, - }; -} - -export function foldConjunction(node: SyntaxNode): FoldRange | null { - if (node.parent?.type?.name === 'PredicateBody') { - return foldWithSibling(node); - } - return foldWholeNode(node); -} diff --git a/subprojects/language-web/src/main/js/language/indentation.ts b/subprojects/language-web/src/main/js/language/indentation.ts deleted file mode 100644 index 6d36ed3b..00000000 --- a/subprojects/language-web/src/main/js/language/indentation.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 indentPredicateOrRule(context: TreeIndentContext): number { - const clauseIndent = indentDeclarationStrategy(context, 1); - if (/^\s+[;.]/.exec(context.textAfter) !== null) { - return clauseIndent - 2; - } - if (/^\s+(~>)/.exec(context.textAfter) !== null) { - return clauseIndent - 3; - } - return clauseIndent; -} diff --git a/subprojects/language-web/src/main/js/language/problem.grammar b/subprojects/language-web/src/main/js/language/problem.grammar deleted file mode 100644 index bccc2e31..00000000 --- a/subprojects/language-web/src/main/js/language/problem.grammar +++ /dev/null @@ -1,149 +0,0 @@ -@detectDelim - -@external prop implicitCompletion from '../../../../src/main/js/language/props.ts' - -@top Problem { statement* } - -statement { - ProblemDeclaration { - ckw<"problem"> QualifiedName "." - } | - ClassDefinition { - ckw<"abstract">? ckw<"class"> RelationName - (ckw<"extends"> sep<",", RelationName>)? - (ClassBody { "{" ReferenceDeclaration* "}" } | ".") - } | - EnumDefinition { - ckw<"enum"> RelationName - (EnumBody { "{" sep<",", IndividualNodeName> "}" } | ".") - } | - PredicateDefinition { - (ckw<"error"> ckw<"pred">? | ckw<"direct">? ckw<"pred">) - RelationName ParameterList? - PredicateBody { ("<->" sep)? "." } - } | - RuleDefinition { - ckw<"direct">? ckw<"rule"> - RuleName ParameterList? - RuleBody { ":" sep "~>" sep "." } - } | - Assertion { - kw<"default">? (NotOp | UnknownOp)? RelationName - ParameterList (":" LogicValue)? "." - } | - NodeValueAssertion { - IndividualNodeName ":" Constant "." - } | - IndividualDeclaration { - ckw<"indiv"> sep<",", IndividualNodeName> "." - } | - ScopeDeclaration { - kw<"scope"> sep<",", ScopeElement> "." - } -} - -ReferenceDeclaration { - (kw<"refers"> | kw<"contains">)? - RelationName - RelationName - ( "[" Multiplicity? "]" )? - (kw<"opposite"> RelationName)? - ";"? -} - -Parameter { RelationName? VariableName } - -Conjunction { ("," | Literal)+ } - -OrOp { ";" } - -Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? } - -Atom { RelationName "+"? ParameterList } - -Action { ("," | ActionLiteral)+ } - -ActionLiteral { - ckw<"new"> VariableName | - ckw<"delete"> VariableName | - Literal -} - -Argument { VariableName | Constant } - -AssertionArgument { NodeName | StarArgument | Constant } - -Constant { Real | String } - -LogicValue { - ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> -} - -ScopeElement { RelationName ("=" | "+=") Multiplicity } - -Multiplicity { (IntMult "..")? (IntMult | StarMult)} - -RelationName { QualifiedName } - -RuleName { QualifiedName } - -IndividualNodeName { QualifiedName } - -VariableName { QualifiedName } - -NodeName { QualifiedName } - -QualifiedName[implicitCompletion=true] { identifier ("::" identifier)* } - -kw { @specialize[@name={term},implicitCompletion=true] } - -ckw { @extend[@name={term},implicitCompletion=true] } - -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 } - - String { - "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" | - "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\"" - } - - NotOp { "!" } - - UnknownOp { "?" } - - StarArgument { "*" } - - "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" "~>" -} - -@detectDelim diff --git a/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts b/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts deleted file mode 100644 index 6508a2c0..00000000 --- a/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { styleTags, tags as t } from '@codemirror/highlight'; -import { - foldInside, - foldNodeProp, - indentNodeProp, - indentUnit, - LanguageSupport, - LRLanguage, -} from '@codemirror/language'; -import { LRParser } from '@lezer/lr'; - -import { parser } from '../../../../build/generated/sources/lezer/problem'; -import { - foldBlockComment, - foldConjunction, - foldDeclaration, - foldWholeNode, -} from './folding'; -import { - indentBlockComment, - indentDeclaration, - indentPredicateOrRule, -} from './indentation'; - -const parserWithMetadata = (parser as LRParser).configure({ - props: [ - styleTags({ - LineComment: t.lineComment, - BlockComment: t.blockComment, - 'problem class enum pred rule indiv scope': t.definitionKeyword, - 'abstract extends refers contains opposite error direct default': t.modifier, - 'true false unknown error': t.keyword, - 'new delete': t.operatorKeyword, - NotOp: t.keyword, - UnknownOp: t.keyword, - OrOp: t.keyword, - StarArgument: t.keyword, - 'IntMult StarMult Real': t.number, - StarMult: t.number, - String: t.string, - 'RelationName/QualifiedName': t.typeName, - 'RuleName/QualifiedName': t.macroName, - 'IndividualNodeName/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: indentPredicateOrRule, - RuleBody: indentPredicateOrRule, - BlockComment: indentBlockComment, - }), - foldNodeProp.add({ - ClassBody: foldInside, - EnumBody: foldInside, - ParameterList: foldInside, - PredicateBody: foldInside, - RuleBody: foldInside, - Conjunction: foldConjunction, - Action: foldWholeNode, - 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, [ - indentUnit.of(' '), - ]); -} diff --git a/subprojects/language-web/src/main/js/language/props.ts b/subprojects/language-web/src/main/js/language/props.ts deleted file mode 100644 index 8e488bf5..00000000 --- a/subprojects/language-web/src/main/js/language/props.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { NodeProp } from '@lezer/common'; - -export const implicitCompletion = new NodeProp({ - deserialize(s: string) { - return s === 'true'; - }, -}); diff --git a/subprojects/language-web/src/main/js/theme/EditorTheme.ts b/subprojects/language-web/src/main/js/theme/EditorTheme.ts deleted file mode 100644 index 1b0dd5de..00000000 --- a/subprojects/language-web/src/main/js/theme/EditorTheme.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { PaletteMode } from '@mui/material'; - -import cssVariables from '../../css/themeVariables.module.scss'; - -export enum EditorTheme { - Light, - Dark, -} - -export class EditorThemeData { - className: string; - - paletteMode: PaletteMode; - - toggleDarkMode: EditorTheme; - - foreground!: string; - - foregroundHighlight!: string; - - background!: string; - - primary!: string; - - secondary!: string; - - constructor(className: string, paletteMode: PaletteMode, toggleDarkMode: EditorTheme) { - this.className = className; - this.paletteMode = paletteMode; - this.toggleDarkMode = toggleDarkMode; - Reflect.ownKeys(this).forEach((key) => { - if (!Reflect.get(this, key)) { - const cssKey = `${this.className}--${key.toString()}`; - if (cssKey in cssVariables) { - Reflect.set(this, key, cssVariables[cssKey]); - } - } - }); - } -} - -export const DEFAULT_THEME = EditorTheme.Dark; - -export const EDITOR_THEMES: { [key in EditorTheme]: EditorThemeData } = { - [EditorTheme.Light]: new EditorThemeData('light', 'light', EditorTheme.Dark), - [EditorTheme.Dark]: new EditorThemeData('dark', 'dark', EditorTheme.Light), -}; diff --git a/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx b/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx deleted file mode 100644 index f5b50be1..00000000 --- a/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import { ThemeProvider as MaterialUiThemeProvider } from '@mui/material/styles'; -import React from 'react'; - -import { useRootStore } from '../RootStore'; - -export const ThemeProvider: React.FC = observer(({ children }) => { - const { themeStore } = useRootStore(); - - return ( - - {children} - - ); -}); diff --git a/subprojects/language-web/src/main/js/theme/ThemeStore.ts b/subprojects/language-web/src/main/js/theme/ThemeStore.ts deleted file mode 100644 index ffaf6dde..00000000 --- a/subprojects/language-web/src/main/js/theme/ThemeStore.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { makeAutoObservable } from 'mobx'; -import { - Theme, - createTheme, - responsiveFontSizes, -} from '@mui/material/styles'; - -import { - EditorTheme, - EditorThemeData, - DEFAULT_THEME, - EDITOR_THEMES, -} from './EditorTheme'; - -export class ThemeStore { - currentTheme: EditorTheme = DEFAULT_THEME; - - constructor() { - makeAutoObservable(this); - } - - toggleDarkMode(): void { - this.currentTheme = this.currentThemeData.toggleDarkMode; - } - - private get currentThemeData(): EditorThemeData { - return EDITOR_THEMES[this.currentTheme]; - } - - get materialUiTheme(): Theme { - const themeData = this.currentThemeData; - const materialUiTheme = createTheme({ - palette: { - mode: themeData.paletteMode, - background: { - default: themeData.background, - paper: themeData.background, - }, - primary: { - main: themeData.primary, - }, - secondary: { - main: themeData.secondary, - }, - error: { - main: themeData.secondary, - }, - text: { - primary: themeData.foregroundHighlight, - secondary: themeData.foreground, - }, - }, - }); - return responsiveFontSizes(materialUiTheme); - } - - get darkMode(): boolean { - return this.currentThemeData.paletteMode === 'dark'; - } - - get className(): string { - return this.currentThemeData.className; - } -} diff --git a/subprojects/language-web/src/main/js/utils/ConditionVariable.ts b/subprojects/language-web/src/main/js/utils/ConditionVariable.ts deleted file mode 100644 index 0910dfa6..00000000 --- a/subprojects/language-web/src/main/js/utils/ConditionVariable.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getLogger } from './logger'; -import { PendingTask } from './PendingTask'; - -const log = getLogger('utils.ConditionVariable'); - -export type Condition = () => boolean; - -export class ConditionVariable { - condition: Condition; - - defaultTimeout: number; - - listeners: PendingTask[] = []; - - constructor(condition: Condition, defaultTimeout = 0) { - this.condition = condition; - this.defaultTimeout = defaultTimeout; - } - - async waitFor(timeoutMs: number | null = null): Promise { - if (this.condition()) { - return; - } - const timeoutOrDefault = timeoutMs || this.defaultTimeout; - let nowMs = Date.now(); - const endMs = nowMs + timeoutOrDefault; - while (!this.condition() && nowMs < endMs) { - const remainingMs = endMs - nowMs; - const promise = new Promise((resolve, reject) => { - if (this.condition()) { - resolve(); - return; - } - const task = new PendingTask(resolve, reject, remainingMs); - this.listeners.push(task); - }); - // We must keep waiting until the update has completed, - // so the tasks can't be started in parallel. - // eslint-disable-next-line no-await-in-loop - await promise; - nowMs = Date.now(); - } - if (!this.condition()) { - log.error('Condition still does not hold after', timeoutOrDefault, 'ms'); - throw new Error('Failed to wait for condition'); - } - } - - notifyAll(): void { - this.clearListenersWith((listener) => listener.resolve()); - } - - rejectAll(error: unknown): void { - this.clearListenersWith((listener) => listener.reject(error)); - } - - private clearListenersWith(callback: (listener: PendingTask) => void) { - // Copy `listeners` so that we don't get into a race condition - // if one of the listeners adds another listener. - const { listeners } = this; - this.listeners = []; - listeners.forEach(callback); - } -} diff --git a/subprojects/language-web/src/main/js/utils/PendingTask.ts b/subprojects/language-web/src/main/js/utils/PendingTask.ts deleted file mode 100644 index 51b79fb0..00000000 --- a/subprojects/language-web/src/main/js/utils/PendingTask.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { getLogger } from './logger'; - -const log = getLogger('utils.PendingTask'); - -export class PendingTask { - private readonly resolveCallback: (value: T) => void; - - private readonly rejectCallback: (reason?: unknown) => void; - - private resolved = false; - - private timeout: number | null; - - constructor( - resolveCallback: (value: T) => void, - rejectCallback: (reason?: unknown) => void, - timeoutMs?: number, - timeoutCallback?: () => void, - ) { - this.resolveCallback = resolveCallback; - this.rejectCallback = rejectCallback; - if (timeoutMs) { - this.timeout = setTimeout(() => { - if (!this.resolved) { - this.reject(new Error('Request timed out')); - if (timeoutCallback) { - timeoutCallback(); - } - } - }, timeoutMs); - } else { - this.timeout = null; - } - } - - resolve(value: T): void { - if (this.resolved) { - log.warn('Trying to resolve already resolved promise'); - return; - } - this.markResolved(); - this.resolveCallback(value); - } - - reject(reason?: unknown): void { - if (this.resolved) { - log.warn('Trying to reject already resolved promise'); - return; - } - this.markResolved(); - this.rejectCallback(reason); - } - - private markResolved() { - this.resolved = true; - if (this.timeout !== null) { - clearTimeout(this.timeout); - } - } -} diff --git a/subprojects/language-web/src/main/js/utils/Timer.ts b/subprojects/language-web/src/main/js/utils/Timer.ts deleted file mode 100644 index 8f653070..00000000 --- a/subprojects/language-web/src/main/js/utils/Timer.ts +++ /dev/null @@ -1,33 +0,0 @@ -export class Timer { - readonly callback: () => void; - - readonly defaultTimeout: number; - - timeout: number | null = null; - - constructor(callback: () => void, defaultTimeout = 0) { - this.callback = () => { - this.timeout = null; - callback(); - }; - this.defaultTimeout = defaultTimeout; - } - - schedule(timeout: number | null = null): void { - if (this.timeout === null) { - this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout); - } - } - - reschedule(timeout: number | null = null): void { - this.cancel(); - this.schedule(timeout); - } - - cancel(): void { - if (this.timeout !== null) { - clearTimeout(this.timeout); - this.timeout = null; - } - } -} diff --git a/subprojects/language-web/src/main/js/utils/logger.ts b/subprojects/language-web/src/main/js/utils/logger.ts deleted file mode 100644 index 306d122c..00000000 --- a/subprojects/language-web/src/main/js/utils/logger.ts +++ /dev/null @@ -1,49 +0,0 @@ -import styles, { CSPair } from 'ansi-styles'; -import log from 'loglevel'; -import * as prefix from 'loglevel-plugin-prefix'; - -const colors: Partial> = { - TRACE: styles.magenta, - DEBUG: styles.cyan, - INFO: styles.blue, - WARN: styles.yellow, - ERROR: styles.red, -}; - -prefix.reg(log); - -if (DEBUG) { - log.setLevel(log.levels.DEBUG); -} else { - log.setLevel(log.levels.WARN); -} - -if ('chrome' in window) { - // Only Chromium supports console ANSI escape sequences. - prefix.apply(log, { - format(level, name, timestamp) { - const formattedTimestamp = `${styles.gray.open}[${timestamp.toString()}]${styles.gray.close}`; - const levelColor = colors[level.toUpperCase()] || styles.red; - const formattedLevel = `${levelColor.open}${level}${levelColor.close}`; - const formattedName = `${styles.green.open}(${name || 'root'})${styles.green.close}`; - return `${formattedTimestamp} ${formattedLevel} ${formattedName}`; - }, - }); -} else { - prefix.apply(log, { - template: '[%t] %l (%n)', - }); -} - -const appLogger = log.getLogger(PACKAGE_NAME); - -appLogger.info('Version:', PACKAGE_NAME, PACKAGE_VERSION); -appLogger.info('Debug mode:', DEBUG); - -export function getLoggerFromRoot(name: string | symbol): log.Logger { - return log.getLogger(name); -} - -export function getLogger(name: string | symbol): log.Logger { - return getLoggerFromRoot(`${PACKAGE_NAME}.${name.toString()}`); -} diff --git a/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts b/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts deleted file mode 100644 index 8b872e06..00000000 --- a/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { - Completion, - CompletionContext, - CompletionResult, -} from '@codemirror/autocomplete'; -import { syntaxTree } from '@codemirror/language'; -import type { Transaction } from '@codemirror/state'; -import escapeStringRegexp from 'escape-string-regexp'; - -import { implicitCompletion } from '../language/props'; -import type { UpdateService } from './UpdateService'; -import { getLogger } from '../utils/logger'; -import type { ContentAssistEntry } from './xtextServiceResults'; - -const PROPOSALS_LIMIT = 1000; - -const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; - -const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; - -const log = getLogger('xtext.ContentAssistService'); - -interface IFoundToken { - from: number; - - to: number; - - implicitCompletion: boolean; - - text: string; -} - -function findToken({ pos, state }: CompletionContext): IFoundToken | null { - const token = syntaxTree(state).resolveInner(pos, -1); - if (token === null) { - return null; - } - if (token.firstChild !== null) { - // We only autocomplete terminal nodes. If the current node is nonterminal, - // returning `null` makes us autocomplete with the empty prefix instead. - return null; - } - return { - from: token.from, - to: token.to, - implicitCompletion: token.type.prop(implicitCompletion) || false, - text: state.sliceDoc(token.from, token.to), - }; -} - -function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { - return token !== null - && token.implicitCompletion - && context.pos - token.from >= 2; -} - -function computeSpan(prefix: string, entryCount: number): RegExp { - const escapedPrefix = escapeStringRegexp(prefix); - if (entryCount < PROPOSALS_LIMIT) { - // Proposals with the current prefix fit the proposals limit. - // We can filter client side as long as the current prefix is preserved. - return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`); - } - // The current prefix overflows the proposals limits, - // so we have to fetch the completions again on the next keypress. - // Hopefully, it'll return a shorter list and we'll be able to filter client side. - return new RegExp(`^${escapedPrefix}$`); -} - -function createCompletion(entry: ContentAssistEntry): Completion { - let boost: number; - switch (entry.kind) { - case 'KEYWORD': - // Some hard-to-type operators should be on top. - boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99; - break; - case 'TEXT': - case 'SNIPPET': - boost = -90; - break; - default: { - // Penalize qualified names (vs available unqualified names). - const extraSegments = entry.proposal.match(/::/g)?.length || 0; - boost = Math.max(-5 * extraSegments, -50); - } - break; - } - return { - label: entry.proposal, - detail: entry.description, - info: entry.documentation, - type: entry.kind?.toLowerCase(), - boost, - }; -} - -export class ContentAssistService { - private readonly updateService: UpdateService; - - private lastCompletion: CompletionResult | null = null; - - constructor(updateService: UpdateService) { - this.updateService = updateService; - } - - onTransaction(transaction: Transaction): void { - if (this.shouldInvalidateCachedCompletion(transaction)) { - this.lastCompletion = null; - } - } - - async contentAssist(context: CompletionContext): Promise { - const tokenBefore = findToken(context); - if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) { - return { - from: context.pos, - options: [], - }; - } - let range: { from: number, to: number }; - let prefix = ''; - if (tokenBefore === null) { - range = { - from: context.pos, - to: context.pos, - }; - prefix = ''; - } else { - range = { - from: tokenBefore.from, - to: tokenBefore.to, - }; - const prefixLength = context.pos - tokenBefore.from; - if (prefixLength > 0) { - prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from); - } - } - if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) { - log.trace('Returning cached completion result'); - // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` - return { - ...this.lastCompletion as CompletionResult, - ...range, - }; - } - this.lastCompletion = null; - const entries = await this.updateService.fetchContentAssist({ - resource: this.updateService.resourceName, - serviceType: 'assist', - caretOffset: context.pos, - proposalsLimit: PROPOSALS_LIMIT, - }, context); - if (context.aborted) { - return { - ...range, - options: [], - }; - } - const options: Completion[] = []; - entries.forEach((entry) => { - if (prefix === entry.prefix) { - // Xtext will generate completions that do not complete the current token, - // e.g., `(` after trying to complete an indetifier, - // but we ignore those, since CodeMirror won't filter for them anyways. - options.push(createCompletion(entry)); - } - }); - log.debug('Fetched', options.length, 'completions from server'); - this.lastCompletion = { - ...range, - options, - span: computeSpan(prefix, entries.length), - }; - return this.lastCompletion; - } - - private shouldReturnCachedCompletion( - token: { from: number, to: number, text: string } | null, - ): boolean { - if (token === null || this.lastCompletion === null) { - return false; - } - const { from, to, text } = token; - const { from: lastFrom, to: lastTo, span } = this.lastCompletion; - if (!lastTo) { - return true; - } - const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); - return from >= transformedFrom - && to <= transformedTo - && typeof span !== 'undefined' - && span.exec(text) !== null; - } - - private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { - if (!transaction.docChanged || this.lastCompletion === null) { - return false; - } - const { from: lastFrom, to: lastTo } = this.lastCompletion; - if (!lastTo) { - return true; - } - const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); - let invalidate = false; - transaction.changes.iterChangedRanges((fromA, toA) => { - if (fromA < transformedFrom || toA > transformedTo) { - invalidate = true; - } - }); - return invalidate; - } - - private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { - const changes = this.updateService.computeChangesSinceLastUpdate(); - const transformedFrom = changes.mapPos(lastFrom); - const transformedTo = changes.mapPos(lastTo, 1); - return [transformedFrom, transformedTo]; - } -} diff --git a/subprojects/language-web/src/main/js/xtext/HighlightingService.ts b/subprojects/language-web/src/main/js/xtext/HighlightingService.ts deleted file mode 100644 index dfbb4a19..00000000 --- a/subprojects/language-web/src/main/js/xtext/HighlightingService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { EditorStore } from '../editor/EditorStore'; -import type { IHighlightRange } from '../editor/semanticHighlighting'; -import type { UpdateService } from './UpdateService'; -import { highlightingResult } from './xtextServiceResults'; - -export class HighlightingService { - private readonly store: EditorStore; - - private readonly updateService: UpdateService; - - constructor(store: EditorStore, updateService: UpdateService) { - this.store = store; - this.updateService = updateService; - } - - onPush(push: unknown): void { - const { regions } = highlightingResult.parse(push); - const allChanges = this.updateService.computeChangesSinceLastUpdate(); - const ranges: IHighlightRange[] = []; - regions.forEach(({ offset, length, styleClasses }) => { - if (styleClasses.length === 0) { - return; - } - const from = allChanges.mapPos(offset); - const to = allChanges.mapPos(offset + length); - if (to <= from) { - return; - } - ranges.push({ - from, - to, - classes: styleClasses, - }); - }); - this.store.updateSemanticHighlighting(ranges); - } -} diff --git a/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts b/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts deleted file mode 100644 index bc865537..00000000 --- a/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Transaction } from '@codemirror/state'; - -import type { EditorStore } from '../editor/EditorStore'; -import type { IOccurrence } from '../editor/findOccurrences'; -import type { UpdateService } from './UpdateService'; -import { getLogger } from '../utils/logger'; -import { Timer } from '../utils/Timer'; -import { XtextWebSocketClient } from './XtextWebSocketClient'; -import { - isConflictResult, - occurrencesResult, - TextRegion, -} from './xtextServiceResults'; - -const FIND_OCCURRENCES_TIMEOUT_MS = 1000; - -// Must clear occurrences asynchronously from `onTransaction`, -// because we must not emit a conflicting transaction when handling the pending transaction. -const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; - -const log = getLogger('xtext.OccurrencesService'); - -function transformOccurrences(regions: TextRegion[]): IOccurrence[] { - const occurrences: IOccurrence[] = []; - regions.forEach(({ offset, length }) => { - if (length > 0) { - occurrences.push({ - from: offset, - to: offset + length, - }); - } - }); - return occurrences; -} - -export class OccurrencesService { - private readonly store: EditorStore; - - private readonly webSocketClient: XtextWebSocketClient; - - private readonly updateService: UpdateService; - - private hasOccurrences = false; - - private readonly findOccurrencesTimer = new Timer(() => { - this.handleFindOccurrences(); - }, FIND_OCCURRENCES_TIMEOUT_MS); - - private readonly clearOccurrencesTimer = new Timer(() => { - this.clearOccurrences(); - }, CLEAR_OCCURRENCES_TIMEOUT_MS); - - constructor( - store: EditorStore, - webSocketClient: XtextWebSocketClient, - updateService: UpdateService, - ) { - this.store = store; - this.webSocketClient = webSocketClient; - this.updateService = updateService; - } - - onTransaction(transaction: Transaction): void { - if (transaction.docChanged) { - this.clearOccurrencesTimer.schedule(); - this.findOccurrencesTimer.reschedule(); - } - if (transaction.isUserEvent('select')) { - this.findOccurrencesTimer.reschedule(); - } - } - - private handleFindOccurrences() { - this.clearOccurrencesTimer.cancel(); - this.updateOccurrences().catch((error) => { - log.error('Unexpected error while updating occurrences', error); - this.clearOccurrences(); - }); - } - - private async updateOccurrences() { - await this.updateService.update(); - const result = await this.webSocketClient.send({ - resource: this.updateService.resourceName, - serviceType: 'occurrences', - expectedStateId: this.updateService.xtextStateId, - caretOffset: this.store.state.selection.main.head, - }); - const allChanges = this.updateService.computeChangesSinceLastUpdate(); - if (!allChanges.empty || isConflictResult(result, 'canceled')) { - // Stale occurrences result, the user already made some changes. - // We can safely ignore the occurrences and schedule a new find occurrences call. - this.clearOccurrences(); - this.findOccurrencesTimer.schedule(); - return; - } - const parsedOccurrencesResult = occurrencesResult.safeParse(result); - if (!parsedOccurrencesResult.success) { - log.error( - 'Unexpected occurences result', - result, - 'not an OccurrencesResult: ', - parsedOccurrencesResult.error, - ); - this.clearOccurrences(); - return; - } - const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; - if (stateId !== this.updateService.xtextStateId) { - log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); - this.clearOccurrences(); - return; - } - const write = transformOccurrences(writeRegions); - const read = transformOccurrences(readRegions); - this.hasOccurrences = write.length > 0 || read.length > 0; - log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); - this.store.updateOccurrences(write, read); - } - - private clearOccurrences() { - if (this.hasOccurrences) { - this.store.updateOccurrences([], []); - this.hasOccurrences = false; - } - } -} diff --git a/subprojects/language-web/src/main/js/xtext/UpdateService.ts b/subprojects/language-web/src/main/js/xtext/UpdateService.ts deleted file mode 100644 index e78944a9..00000000 --- a/subprojects/language-web/src/main/js/xtext/UpdateService.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - ChangeDesc, - ChangeSet, - ChangeSpec, - StateEffect, - Transaction, -} from '@codemirror/state'; -import { nanoid } from 'nanoid'; - -import type { EditorStore } from '../editor/EditorStore'; -import type { XtextWebSocketClient } from './XtextWebSocketClient'; -import { ConditionVariable } from '../utils/ConditionVariable'; -import { getLogger } from '../utils/logger'; -import { Timer } from '../utils/Timer'; -import { - ContentAssistEntry, - contentAssistResult, - documentStateResult, - formattingResult, - isConflictResult, -} from './xtextServiceResults'; - -const UPDATE_TIMEOUT_MS = 500; - -const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; - -const log = getLogger('xtext.UpdateService'); - -const setDirtyChanges = StateEffect.define(); - -export interface IAbortSignal { - aborted: boolean; -} - -export class UpdateService { - resourceName: string; - - xtextStateId: string | null = null; - - private readonly store: EditorStore; - - /** - * The changes being synchronized to the server if a full or delta text update is running, - * `null` otherwise. - */ - private pendingUpdate: ChangeSet | null = null; - - /** - * Local changes not yet sychronized to the server and not part of the running update, if any. - */ - private dirtyChanges: ChangeSet; - - private readonly webSocketClient: XtextWebSocketClient; - - private readonly updatedCondition = new ConditionVariable( - () => this.pendingUpdate === null && this.xtextStateId !== null, - WAIT_FOR_UPDATE_TIMEOUT_MS, - ); - - private readonly idleUpdateTimer = new Timer(() => { - this.handleIdleUpdate(); - }, UPDATE_TIMEOUT_MS); - - constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { - this.resourceName = `${nanoid(7)}.problem`; - this.store = store; - this.dirtyChanges = this.newEmptyChangeSet(); - this.webSocketClient = webSocketClient; - } - - onReconnect(): void { - this.xtextStateId = null; - this.updateFullText().catch((error) => { - log.error('Unexpected error during initial update', error); - }); - } - - onTransaction(transaction: Transaction): void { - const setDirtyChangesEffect = transaction.effects.find( - (effect) => effect.is(setDirtyChanges), - ) as StateEffect | undefined; - if (setDirtyChangesEffect) { - const { value } = setDirtyChangesEffect; - if (this.pendingUpdate !== null) { - this.pendingUpdate = ChangeSet.empty(value.length); - } - this.dirtyChanges = value; - return; - } - if (transaction.docChanged) { - this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); - this.idleUpdateTimer.reschedule(); - } - } - - /** - * Computes the summary of any changes happened since the last complete update. - * - * The result reflects any changes that happened since the `xtextStateId` - * version was uploaded to the server. - * - * @return the summary of changes since the last update - */ - computeChangesSinceLastUpdate(): ChangeDesc { - return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; - } - - private handleIdleUpdate() { - if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) { - return; - } - if (this.pendingUpdate === null) { - this.update().catch((error) => { - log.error('Unexpected error during scheduled update', error); - }); - } - this.idleUpdateTimer.reschedule(); - } - - private newEmptyChangeSet() { - return ChangeSet.of([], this.store.state.doc.length); - } - - async updateFullText(): Promise { - await this.withUpdate(() => this.doUpdateFullText()); - } - - private async doUpdateFullText(): Promise<[string, void]> { - const result = await this.webSocketClient.send({ - resource: this.resourceName, - serviceType: 'update', - fullText: this.store.state.doc.sliceString(0), - }); - const { stateId } = documentStateResult.parse(result); - return [stateId, undefined]; - } - - /** - * Makes sure that the document state on the server reflects recent - * local changes. - * - * Performs either an update with delta text or a full text update if needed. - * If there are not local dirty changes, the promise resolves immediately. - * - * @return a promise resolving when the update is completed - */ - async update(): Promise { - await this.prepareForDeltaUpdate(); - const delta = this.computeDelta(); - if (delta === null) { - return; - } - log.trace('Editor delta', delta); - await this.withUpdate(async () => { - const result = await this.webSocketClient.send({ - resource: this.resourceName, - serviceType: 'update', - requiredStateId: this.xtextStateId, - ...delta, - }); - const parsedDocumentStateResult = documentStateResult.safeParse(result); - if (parsedDocumentStateResult.success) { - return [parsedDocumentStateResult.data.stateId, undefined]; - } - if (isConflictResult(result, 'invalidStateId')) { - return this.doFallbackToUpdateFullText(); - } - throw parsedDocumentStateResult.error; - }); - } - - private doFallbackToUpdateFullText() { - if (this.pendingUpdate === null) { - throw new Error('Only a pending update can be extended'); - } - log.warn('Delta update failed, performing full text update'); - this.xtextStateId = null; - this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); - this.dirtyChanges = this.newEmptyChangeSet(); - return this.doUpdateFullText(); - } - - async fetchContentAssist( - params: Record, - signal: IAbortSignal, - ): Promise { - await this.prepareForDeltaUpdate(); - if (signal.aborted) { - return []; - } - const delta = this.computeDelta(); - if (delta !== null) { - log.trace('Editor delta', delta); - const entries = await this.withUpdate(async () => { - const result = await this.webSocketClient.send({ - ...params, - requiredStateId: this.xtextStateId, - ...delta, - }); - const parsedContentAssistResult = contentAssistResult.safeParse(result); - if (parsedContentAssistResult.success) { - const { stateId, entries: resultEntries } = parsedContentAssistResult.data; - return [stateId, resultEntries]; - } - if (isConflictResult(result, 'invalidStateId')) { - log.warn('Server state invalid during content assist'); - const [newStateId] = await this.doFallbackToUpdateFullText(); - // We must finish this state update transaction to prepare for any push events - // before querying for content assist, so we just return `null` and will query - // the content assist service later. - return [newStateId, null]; - } - throw parsedContentAssistResult.error; - }); - if (entries !== null) { - return entries; - } - if (signal.aborted) { - return []; - } - } - // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null` - return this.doFetchContentAssist(params, this.xtextStateId as string); - } - - private async doFetchContentAssist(params: Record, expectedStateId: string) { - const result = await this.webSocketClient.send({ - ...params, - requiredStateId: expectedStateId, - }); - const { stateId, entries } = contentAssistResult.parse(result); - if (stateId !== expectedStateId) { - throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); - } - return entries; - } - - async formatText(): Promise { - await this.update(); - let { from, to } = this.store.state.selection.main; - if (to <= from) { - from = 0; - to = this.store.state.doc.length; - } - log.debug('Formatting from', from, 'to', to); - await this.withUpdate(async () => { - const result = await this.webSocketClient.send({ - resource: this.resourceName, - serviceType: 'format', - selectionStart: from, - selectionEnd: to, - }); - const { stateId, formattedText } = formattingResult.parse(result); - this.applyBeforeDirtyChanges({ - from, - to, - insert: formattedText, - }); - return [stateId, null]; - }); - } - - private computeDelta() { - if (this.dirtyChanges.empty) { - return null; - } - let minFromA = Number.MAX_SAFE_INTEGER; - let maxToA = 0; - let minFromB = Number.MAX_SAFE_INTEGER; - let maxToB = 0; - this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => { - minFromA = Math.min(minFromA, fromA); - maxToA = Math.max(maxToA, toA); - minFromB = Math.min(minFromB, fromB); - maxToB = Math.max(maxToB, toB); - }); - return { - deltaOffset: minFromA, - deltaReplaceLength: maxToA - minFromA, - deltaText: this.store.state.doc.sliceString(minFromB, maxToB), - }; - } - - private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { - const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; - const revertChanges = pendingChanges.invert(this.store.state.doc); - const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); - const redoChanges = pendingChanges.map(applyBefore.desc); - const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); - this.store.dispatch({ - changes: changeSet, - effects: [ - setDirtyChanges.of(redoChanges), - ], - }); - } - - /** - * Executes an asynchronous callback that updates the state on the server. - * - * Ensures that updates happen sequentially and manages `pendingUpdate` - * and `dirtyChanges` to reflect changes being synchronized to the server - * and not yet synchronized to the server, respectively. - * - * Optionally, `callback` may return a second value that is retured by this function. - * - * Once the remote procedure call to update the server state finishes - * and returns the new `stateId`, `callback` must return _immediately_ - * to ensure that the local `stateId` is updated likewise to be able to handle - * push messages referring to the new `stateId` from the server. - * If additional work is needed to compute the second value in some cases, - * use `T | null` instead of `T` as a return type and signal the need for additional - * computations by returning `null`. Thus additional computations can be performed - * outside of the critical section. - * - * @param callback the asynchronous callback that updates the server state - * @return a promise resolving to the second value returned by `callback` - */ - private async withUpdate(callback: () => Promise<[string, T]>): Promise { - if (this.pendingUpdate !== null) { - throw new Error('Another update is pending, will not perform update'); - } - this.pendingUpdate = this.dirtyChanges; - this.dirtyChanges = this.newEmptyChangeSet(); - let newStateId: string | null = null; - try { - let result: T; - [newStateId, result] = await callback(); - this.xtextStateId = newStateId; - this.pendingUpdate = null; - this.updatedCondition.notifyAll(); - return result; - } catch (e) { - log.error('Error while update', e); - if (this.pendingUpdate === null) { - log.error('pendingUpdate was cleared during update'); - } else { - this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); - } - this.pendingUpdate = null; - this.webSocketClient.forceReconnectOnError(); - this.updatedCondition.rejectAll(e); - throw e; - } - } - - /** - * Ensures that there is some state available on the server (`xtextStateId`) - * and that there is not pending update. - * - * After this function resolves, a delta text update is possible. - * - * @return a promise resolving when there is a valid state id but no pending update - */ - private async prepareForDeltaUpdate() { - // If no update is pending, but the full text hasn't been uploaded to the server yet, - // we must start a full text upload. - if (this.pendingUpdate === null && this.xtextStateId === null) { - await this.updateFullText(); - } - await this.updatedCondition.waitFor(); - } -} diff --git a/subprojects/language-web/src/main/js/xtext/ValidationService.ts b/subprojects/language-web/src/main/js/xtext/ValidationService.ts deleted file mode 100644 index ff7d3700..00000000 --- a/subprojects/language-web/src/main/js/xtext/ValidationService.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Diagnostic } from '@codemirror/lint'; - -import type { EditorStore } from '../editor/EditorStore'; -import type { UpdateService } from './UpdateService'; -import { validationResult } from './xtextServiceResults'; - -export class ValidationService { - private readonly store: EditorStore; - - private readonly updateService: UpdateService; - - constructor(store: EditorStore, updateService: UpdateService) { - this.store = store; - this.updateService = updateService; - } - - onPush(push: unknown): void { - const { issues } = validationResult.parse(push); - const allChanges = this.updateService.computeChangesSinceLastUpdate(); - const diagnostics: Diagnostic[] = []; - issues.forEach(({ - offset, - length, - severity, - description, - }) => { - if (severity === 'ignore') { - return; - } - diagnostics.push({ - from: allChanges.mapPos(offset), - to: allChanges.mapPos(offset + length), - severity, - message: description, - }); - }); - this.store.updateDiagnostics(diagnostics); - } -} diff --git a/subprojects/language-web/src/main/js/xtext/XtextClient.ts b/subprojects/language-web/src/main/js/xtext/XtextClient.ts deleted file mode 100644 index 0898e725..00000000 --- a/subprojects/language-web/src/main/js/xtext/XtextClient.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { - CompletionContext, - CompletionResult, -} from '@codemirror/autocomplete'; -import type { Transaction } from '@codemirror/state'; - -import type { EditorStore } from '../editor/EditorStore'; -import { ContentAssistService } from './ContentAssistService'; -import { HighlightingService } from './HighlightingService'; -import { OccurrencesService } from './OccurrencesService'; -import { UpdateService } from './UpdateService'; -import { getLogger } from '../utils/logger'; -import { ValidationService } from './ValidationService'; -import { XtextWebSocketClient } from './XtextWebSocketClient'; -import { XtextWebPushService } from './xtextMessages'; - -const log = getLogger('xtext.XtextClient'); - -export class XtextClient { - private readonly webSocketClient: XtextWebSocketClient; - - private readonly updateService: UpdateService; - - private readonly contentAssistService: ContentAssistService; - - private readonly highlightingService: HighlightingService; - - private readonly validationService: ValidationService; - - private readonly occurrencesService: OccurrencesService; - - constructor(store: EditorStore) { - this.webSocketClient = new XtextWebSocketClient( - () => this.updateService.onReconnect(), - (resource, stateId, service, push) => this.onPush(resource, stateId, service, push), - ); - this.updateService = new UpdateService(store, this.webSocketClient); - this.contentAssistService = new ContentAssistService(this.updateService); - this.highlightingService = new HighlightingService(store, this.updateService); - this.validationService = new ValidationService(store, this.updateService); - this.occurrencesService = new OccurrencesService( - store, - this.webSocketClient, - this.updateService, - ); - } - - onTransaction(transaction: Transaction): void { - // `ContentAssistService.prototype.onTransaction` needs the dirty change desc - // _before_ the current edit, so we call it before `updateService`. - this.contentAssistService.onTransaction(transaction); - this.updateService.onTransaction(transaction); - this.occurrencesService.onTransaction(transaction); - } - - private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { - const { resourceName, xtextStateId } = this.updateService; - if (resource !== resourceName) { - log.error('Unknown resource name: expected:', resourceName, 'got:', resource); - return; - } - if (stateId !== xtextStateId) { - log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId); - // The current push message might be stale (referring to a previous state), - // so this is not neccessarily an error and there is no need to force-reconnect. - return; - } - switch (service) { - case 'highlight': - this.highlightingService.onPush(push); - return; - case 'validate': - this.validationService.onPush(push); - } - } - - contentAssist(context: CompletionContext): Promise { - return this.contentAssistService.contentAssist(context); - } - - formatText(): void { - this.updateService.formatText().catch((e) => { - log.error('Error while formatting text', e); - }); - } -} diff --git a/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts deleted file mode 100644 index 2ce20a54..00000000 --- a/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { nanoid } from 'nanoid'; - -import { getLogger } from '../utils/logger'; -import { PendingTask } from '../utils/PendingTask'; -import { Timer } from '../utils/Timer'; -import { - xtextWebErrorResponse, - XtextWebRequest, - xtextWebOkResponse, - xtextWebPushMessage, - XtextWebPushService, -} from './xtextMessages'; -import { pongResult } from './xtextServiceResults'; - -const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; - -const WEBSOCKET_CLOSE_OK = 1000; - -const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; - -const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; - -const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; - -const PING_TIMEOUT_MS = 10 * 1000; - -const REQUEST_TIMEOUT_MS = 1000; - -const log = getLogger('xtext.XtextWebSocketClient'); - -export type ReconnectHandler = () => void; - -export type PushHandler = ( - resourceId: string, - stateId: string, - service: XtextWebPushService, - data: unknown, -) => void; - -enum State { - Initial, - Opening, - TabVisible, - TabHiddenIdle, - TabHiddenWaiting, - Error, - TimedOut, -} - -export class XtextWebSocketClient { - private nextMessageId = 0; - - private connection!: WebSocket; - - private readonly pendingRequests = new Map>(); - - private readonly onReconnect: ReconnectHandler; - - private readonly onPush: PushHandler; - - private state = State.Initial; - - private reconnectTryCount = 0; - - private readonly idleTimer = new Timer(() => { - this.handleIdleTimeout(); - }, BACKGROUND_IDLE_TIMEOUT_MS); - - private readonly pingTimer = new Timer(() => { - this.sendPing(); - }, PING_TIMEOUT_MS); - - private readonly reconnectTimer = new Timer(() => { - this.handleReconnect(); - }); - - constructor(onReconnect: ReconnectHandler, onPush: PushHandler) { - this.onReconnect = onReconnect; - this.onPush = onPush; - document.addEventListener('visibilitychange', () => { - this.handleVisibilityChange(); - }); - this.reconnect(); - } - - private get isLogicallyClosed(): boolean { - return this.state === State.Error || this.state === State.TimedOut; - } - - get isOpen(): boolean { - return this.state === State.TabVisible - || this.state === State.TabHiddenIdle - || this.state === State.TabHiddenWaiting; - } - - private reconnect() { - if (this.isOpen || this.state === State.Opening) { - log.error('Trying to reconnect from', this.state); - return; - } - this.state = State.Opening; - const webSocketServer = window.origin.replace(/^http/, 'ws'); - const webSocketUrl = `${webSocketServer}/xtext-service`; - this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); - this.connection.addEventListener('open', () => { - if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { - log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); - this.forceReconnectOnError(); - } - if (document.visibilityState === 'hidden') { - this.handleTabHidden(); - } else { - this.handleTabVisibleConnected(); - } - log.info('Connected to websocket'); - this.nextMessageId = 0; - this.reconnectTryCount = 0; - this.pingTimer.schedule(); - this.onReconnect(); - }); - this.connection.addEventListener('error', (event) => { - log.error('Unexpected websocket error', event); - this.forceReconnectOnError(); - }); - this.connection.addEventListener('message', (event) => { - this.handleMessage(event.data); - }); - this.connection.addEventListener('close', (event) => { - if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK - && this.pendingRequests.size === 0) { - log.info('Websocket closed'); - return; - } - log.error('Websocket closed unexpectedly', event.code, event.reason); - this.forceReconnectOnError(); - }); - } - - private handleVisibilityChange() { - if (document.visibilityState === 'hidden') { - if (this.state === State.TabVisible) { - this.handleTabHidden(); - } - return; - } - this.idleTimer.cancel(); - if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) { - this.handleTabVisibleConnected(); - return; - } - if (this.state === State.TimedOut) { - this.reconnect(); - } - } - - private handleTabHidden() { - log.debug('Tab hidden while websocket is connected'); - this.state = State.TabHiddenIdle; - this.idleTimer.schedule(); - } - - private handleTabVisibleConnected() { - log.debug('Tab visible while websocket is connected'); - this.state = State.TabVisible; - } - - private handleIdleTimeout() { - log.trace('Waiting for pending tasks before disconnect'); - if (this.state === State.TabHiddenIdle) { - this.state = State.TabHiddenWaiting; - this.handleWaitingForDisconnect(); - } - } - - private handleWaitingForDisconnect() { - if (this.state !== State.TabHiddenWaiting) { - return; - } - const pending = this.pendingRequests.size; - if (pending === 0) { - log.info('Closing idle websocket'); - this.state = State.TimedOut; - this.closeConnection(1000, 'idle timeout'); - return; - } - log.info('Waiting for', pending, 'pending requests before closing websocket'); - } - - private sendPing() { - if (!this.isOpen) { - return; - } - const ping = nanoid(); - log.trace('Ping', ping); - this.send({ ping }).then((result) => { - const parsedPongResult = pongResult.safeParse(result); - if (parsedPongResult.success && parsedPongResult.data.pong === ping) { - log.trace('Pong', ping); - this.pingTimer.schedule(); - } else { - log.error('Invalid pong:', parsedPongResult, 'expected:', ping); - this.forceReconnectOnError(); - } - }).catch((error) => { - log.error('Error while waiting for ping', error); - this.forceReconnectOnError(); - }); - } - - send(request: unknown): Promise { - if (!this.isOpen) { - throw new Error('Not open'); - } - const messageId = this.nextMessageId.toString(16); - if (messageId in this.pendingRequests) { - log.error('Message id wraparound still pending', messageId); - this.rejectRequest(messageId, new Error('Message id wraparound')); - } - if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) { - this.nextMessageId = 0; - } else { - this.nextMessageId += 1; - } - const message = JSON.stringify({ - id: messageId, - request, - } as XtextWebRequest); - log.trace('Sending message', message); - return new Promise((resolve, reject) => { - const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { - this.removePendingRequest(messageId); - }); - this.pendingRequests.set(messageId, task); - this.connection.send(message); - }); - } - - private handleMessage(messageStr: unknown) { - if (typeof messageStr !== 'string') { - log.error('Unexpected binary message', messageStr); - this.forceReconnectOnError(); - return; - } - log.trace('Incoming websocket message', messageStr); - let message: unknown; - try { - message = JSON.parse(messageStr); - } catch (error) { - log.error('Json parse error', error); - this.forceReconnectOnError(); - return; - } - const okResponse = xtextWebOkResponse.safeParse(message); - if (okResponse.success) { - const { id, response } = okResponse.data; - this.resolveRequest(id, response); - return; - } - const errorResponse = xtextWebErrorResponse.safeParse(message); - if (errorResponse.success) { - const { id, error, message: errorMessage } = errorResponse.data; - this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); - if (error === 'server') { - log.error('Reconnecting due to server error: ', errorMessage); - this.forceReconnectOnError(); - } - return; - } - const pushMessage = xtextWebPushMessage.safeParse(message); - if (pushMessage.success) { - const { - resource, - stateId, - service, - push, - } = pushMessage.data; - this.onPush(resource, stateId, service, push); - } else { - log.error( - 'Unexpected websocket message:', - message, - 'not ok response because:', - okResponse.error, - 'not error response because:', - errorResponse.error, - 'not push message because:', - pushMessage.error, - ); - this.forceReconnectOnError(); - } - } - - private resolveRequest(messageId: string, value: unknown) { - const pendingRequest = this.pendingRequests.get(messageId); - if (pendingRequest) { - pendingRequest.resolve(value); - this.removePendingRequest(messageId); - return; - } - log.error('Trying to resolve unknown request', messageId, 'with', value); - } - - private rejectRequest(messageId: string, reason?: unknown) { - const pendingRequest = this.pendingRequests.get(messageId); - if (pendingRequest) { - pendingRequest.reject(reason); - this.removePendingRequest(messageId); - return; - } - log.error('Trying to reject unknown request', messageId, 'with', reason); - } - - private removePendingRequest(messageId: string) { - this.pendingRequests.delete(messageId); - this.handleWaitingForDisconnect(); - } - - forceReconnectOnError(): void { - if (this.isLogicallyClosed) { - return; - } - this.abortPendingRequests(); - this.closeConnection(1000, 'reconnecting due to error'); - log.error('Reconnecting after delay due to error'); - this.handleErrorState(); - } - - private abortPendingRequests() { - this.pendingRequests.forEach((request) => { - request.reject(new Error('Websocket disconnect')); - }); - this.pendingRequests.clear(); - } - - private closeConnection(code: number, reason: string) { - this.pingTimer.cancel(); - const { readyState } = this.connection; - if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { - this.connection.close(code, reason); - } - } - - private handleErrorState() { - this.state = State.Error; - this.reconnectTryCount += 1; - const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS; - log.info('Reconnecting in', delay, 'ms'); - this.reconnectTimer.schedule(delay); - } - - private handleReconnect() { - if (this.state !== State.Error) { - log.error('Unexpected reconnect in', this.state); - return; - } - if (document.visibilityState === 'hidden') { - this.state = State.TimedOut; - } else { - this.reconnect(); - } - } -} diff --git a/subprojects/language-web/src/main/js/xtext/xtextMessages.ts b/subprojects/language-web/src/main/js/xtext/xtextMessages.ts deleted file mode 100644 index c4305fcf..00000000 --- a/subprojects/language-web/src/main/js/xtext/xtextMessages.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from 'zod'; - -export const xtextWebRequest = z.object({ - id: z.string().nonempty(), - request: z.unknown(), -}); - -export type XtextWebRequest = z.infer; - -export const xtextWebOkResponse = z.object({ - id: z.string().nonempty(), - response: z.unknown(), -}); - -export type XtextWebOkResponse = z.infer; - -export const xtextWebErrorKind = z.enum(['request', 'server']); - -export type XtextWebErrorKind = z.infer; - -export const xtextWebErrorResponse = z.object({ - id: z.string().nonempty(), - error: xtextWebErrorKind, - message: z.string(), -}); - -export type XtextWebErrorResponse = z.infer; - -export const xtextWebPushService = z.enum(['highlight', 'validate']); - -export type XtextWebPushService = z.infer; - -export const xtextWebPushMessage = z.object({ - resource: z.string().nonempty(), - stateId: z.string().nonempty(), - service: xtextWebPushService, - push: z.unknown(), -}); - -export type XtextWebPushMessage = z.infer; diff --git a/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts b/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts deleted file mode 100644 index f79b059c..00000000 --- a/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { z } from 'zod'; - -export const pongResult = z.object({ - pong: z.string().nonempty(), -}); - -export type PongResult = z.infer; - -export const documentStateResult = z.object({ - stateId: z.string().nonempty(), -}); - -export type DocumentStateResult = z.infer; - -export const conflict = z.enum(['invalidStateId', 'canceled']); - -export type Conflict = z.infer; - -export const serviceConflictResult = z.object({ - conflict, -}); - -export type ServiceConflictResult = z.infer; - -export function isConflictResult(result: unknown, conflictType: Conflict): boolean { - const parsedConflictResult = serviceConflictResult.safeParse(result); - return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; -} - -export const severity = z.enum(['error', 'warning', 'info', 'ignore']); - -export type Severity = z.infer; - -export const issue = z.object({ - description: z.string().nonempty(), - severity, - line: z.number().int(), - column: z.number().int().nonnegative(), - offset: z.number().int().nonnegative(), - length: z.number().int().nonnegative(), -}); - -export type Issue = z.infer; - -export const validationResult = z.object({ - issues: issue.array(), -}); - -export type ValidationResult = z.infer; - -export const replaceRegion = z.object({ - offset: z.number().int().nonnegative(), - length: z.number().int().nonnegative(), - text: z.string(), -}); - -export type ReplaceRegion = z.infer; - -export const textRegion = z.object({ - offset: z.number().int().nonnegative(), - length: z.number().int().nonnegative(), -}); - -export type TextRegion = z.infer; - -export const contentAssistEntry = z.object({ - prefix: z.string(), - proposal: z.string().nonempty(), - label: z.string().optional(), - description: z.string().nonempty().optional(), - documentation: z.string().nonempty().optional(), - escapePosition: z.number().int().nonnegative().optional(), - textReplacements: replaceRegion.array(), - editPositions: textRegion.array(), - kind: z.string().nonempty(), -}); - -export type ContentAssistEntry = z.infer; - -export const contentAssistResult = documentStateResult.extend({ - entries: contentAssistEntry.array(), -}); - -export type ContentAssistResult = z.infer; - -export const highlightingRegion = z.object({ - offset: z.number().int().nonnegative(), - length: z.number().int().nonnegative(), - styleClasses: z.string().nonempty().array(), -}); - -export type HighlightingRegion = z.infer; - -export const highlightingResult = z.object({ - regions: highlightingRegion.array(), -}); - -export type HighlightingResult = z.infer; - -export const occurrencesResult = documentStateResult.extend({ - writeRegions: textRegion.array(), - readRegions: textRegion.array(), -}); - -export type OccurrencesResult = z.infer; - -export const formattingResult = documentStateResult.extend({ - formattedText: z.string(), - replaceRegion: textRegion, -}); - -export type FormattingResult = z.infer; diff --git a/subprojects/language-web/tsconfig.json b/subprojects/language-web/tsconfig.json deleted file mode 100644 index cb5f6b13..00000000 --- a/subprojects/language-web/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "module": "esnext", - "moduleResolution": "node", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "jsx": "react", - "strict": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "exactOptionalPropertyTypes": false, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["./src/main/js/**/*"], - "exclude": ["./build/generated/sources/lezer/*"] -} diff --git a/subprojects/language-web/tsconfig.sonar.json b/subprojects/language-web/tsconfig.sonar.json deleted file mode 100644 index 54eef68b..00000000 --- a/subprojects/language-web/tsconfig.sonar.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "module": "esnext", - "moduleResolution": "node", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "jsx": "react", - "strict": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noEmit": true, - "skipLibCheck": true - }, - "include": ["./src/main/js/**/*"], - "exclude": ["./src/main/js/xtext/**/*"] -} diff --git a/subprojects/language-web/webpack.config.js b/subprojects/language-web/webpack.config.js deleted file mode 100644 index 801a705c..00000000 --- a/subprojects/language-web/webpack.config.js +++ /dev/null @@ -1,232 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const { DefinePlugin } = require('webpack'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const HtmlWebpackInjectPreload = require('@principalstudio/html-webpack-inject-preload'); -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity'); - -const packageInfo = require('./package.json'); - -const currentNodeEnv = process.env.NODE_ENV || 'development'; -const devMode = currentNodeEnv !== 'production'; -const outputPath = path.resolve(__dirname, 'build/webpack', currentNodeEnv); - -const portNumberOrElse = (envName, fallback) => { - const value = process.env[envName]; - return value ? parseInt(value) : fallback; -}; -const listenHost = process.env['LISTEN_HOST'] || 'localhost'; -const listenPort = portNumberOrElse('LISTEN_PORT', 1313); -const apiHost = process.env['API_HOST'] || listenHost; -const apiPort = portNumberOrElse('API_PORT', 1312); -const publicHost = process.env['PUBLIC_HOST'] || listenHost; -const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort); - -const resolveSources = sources => path.resolve(__dirname, 'src', sources); -const mainJsSources = resolveSources('main/js'); -const babelLoaderFilters = { - include: [mainJsSources], -}; -const babelPresets = [ - [ - '@babel/preset-env', - { - targets: 'defaults', - }, - ], - '@babel/preset-react', -]; -const babelPlugins = [ - '@babel/plugin-transform-runtime', -] -const magicCommentsLoader = { - loader: 'magic-comments-loader', - options: { - webpackChunkName: true, - } -}; - -module.exports = { - mode: devMode ? 'development' : 'production', - entry: './src/main/js', - output: { - path: outputPath, - publicPath: '/', - filename: devMode ? '[name].js' : '[name].[contenthash].js', - chunkFilename: devMode ? '[name].js' : '[name].[contenthash].js', - assetModuleFilename: devMode ? '[name].js' : '[name].[contenthash][ext]', - clean: true, - crossOriginLoading: 'anonymous', - }, - module: { - rules: [ - { - test: /\.jsx?$/i, - ...babelLoaderFilters, - use: [ - { - loader: 'babel-loader', - options: { - presets: babelPresets, - plugins: [ - [ - '@babel/plugin-proposal-class-properties', - { - loose: false, - }, - ...babelPlugins, - ], - ], - assumptions: { - 'setPublicClassFields': false, - }, - }, - }, - magicCommentsLoader, - ], - }, - { - test: /.tsx?$/i, - ...babelLoaderFilters, - use: [ - { - loader: 'babel-loader', - options: { - presets: [ - ...babelPresets, - [ - '@babel/preset-typescript', - { - isTSX: true, - allExtensions: true, - allowDeclareFields: true, - onlyRemoveTypeImports: true, - optimizeConstEnums: true, - }, - ] - ], - plugins: babelPlugins, - }, - }, - magicCommentsLoader, - ], - }, - { - test: /\.scss$/i, - use: [ - devMode ? 'style-loader' : MiniCssExtractPlugin.loader, - 'css-loader', - { - loader: 'sass-loader', - options: { - implementation: require.resolve('sass'), - }, - }, - ], - }, - { - test: /\.(gif|png|jpe?g|svg?)$/i, - use: [ - { - loader: 'image-webpack-loader', - options: { - disable: true, - } - }, - ], - type: 'asset', - }, - { - test: /\.woff2?$/i, - type: 'asset/resource', - }, - ], - }, - resolve: { - modules: [ - 'node_modules', - mainJsSources, - ], - extensions: ['.js', '.jsx', '.ts', '.tsx'], - }, - devtool: devMode ? 'inline-source-map' : 'source-map', - optimization: { - providedExports: !devMode, - sideEffects: devMode ? 'flag' : true, - splitChunks: { - chunks: 'all', - cacheGroups: { - defaultVendors: { - test: /[\\/]node_modules[\\/]/, - priority: -10, - reuseExistingChunk: true, - filename: devMode ? 'vendor.[id].js' : 'vendor.[contenthash].js', - }, - default: { - minChunks: 2, - priority: -20, - reuseExistingChunk: true, - }, - }, - }, - }, - devServer: { - client: { - logging: 'info', - overlay: true, - progress: true, - webSocketURL: { - hostname: publicHost, - port: publicPort, - protocol: publicPort === 443 ? 'wss' : 'ws', - }, - }, - compress: true, - host: listenHost, - port: listenPort, - proxy: { - '/xtext-service': { - target: `${apiPort === 443 ? 'https' : 'http'}://${apiHost}:${apiPort}`, - ws: true, - }, - }, - }, - plugins: [ - new DefinePlugin({ - 'DEBUG': JSON.stringify(devMode), - 'PACKAGE_NAME': JSON.stringify(packageInfo.name), - 'PACKAGE_VERSION': JSON.stringify(packageInfo.version), - }), - new MiniCssExtractPlugin({ - filename: '[name].[contenthash].css', - chunkFilename: '[name].[contenthash].css', - }), - new SubresourceIntegrityPlugin(), - new HtmlWebpackPlugin({ - template: 'src/main/html/index.html', - minify: devMode ? false : { - collapseWhitespace: true, - removeComments: true, - removeOptionalTags: true, - removeRedundantAttributes: true, - removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true, - useShortDoctype: true, - }, - }), - new HtmlWebpackInjectPreload({ - files: [ - { - match: /(roboto-latin-(400|500)-normal|jetbrains-mono-latin-variable).*\.woff2/, - attributes: { - as: 'font', - type: 'font/woff2', - crossorigin: 'anonymous', - }, - }, - ], - }), - ], -}; diff --git a/yarn.lock b/yarn.lock index 0c714448..2c5ff657 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1608,16 +1608,16 @@ __metadata: languageName: node linkType: hard -"@emotion/cache@npm:^11.6.0": - version: 11.6.0 - resolution: "@emotion/cache@npm:11.6.0" +"@emotion/cache@npm:^11.6.0, @emotion/cache@npm:^11.7.1": + version: 11.7.1 + resolution: "@emotion/cache@npm:11.7.1" dependencies: "@emotion/memoize": ^0.7.4 "@emotion/sheet": ^1.1.0 "@emotion/utils": ^1.0.0 "@emotion/weak-memoize": ^0.2.5 - stylis: ^4.0.10 - checksum: 3c72c50bfe06fd7ec2728988181173664183d2320305a01bb757caa6a9d485de25fccde1b173d810423d2ff88a178d5a72cc21db76b9e458311a8ce3044dd2a1 + stylis: 4.0.13 + checksum: cf7aa8fe3bacfdedcda94b53e76a7635e122043439715fcfbf7f1a81340cfe6099a59134481a03ec3e0437466566d18528577d1e6ea92f5b98c372b8b38a8f35 languageName: node linkType: hard @@ -1644,12 +1644,12 @@ __metadata: languageName: node linkType: hard -"@emotion/react@npm:^11.7.0": - version: 11.7.0 - resolution: "@emotion/react@npm:11.7.0" +"@emotion/react@npm:^11.7.1": + version: 11.7.1 + resolution: "@emotion/react@npm:11.7.1" dependencies: "@babel/runtime": ^7.13.10 - "@emotion/cache": ^11.6.0 + "@emotion/cache": ^11.7.1 "@emotion/serialize": ^1.0.2 "@emotion/sheet": ^1.1.0 "@emotion/utils": ^1.0.0 @@ -1663,7 +1663,7 @@ __metadata: optional: true "@types/react": optional: true - checksum: 8b50d61caabe04ae413ae23b98c170da643754ec89f25eb001464095685686585d0f88988bc36432f231e6de6abdbee73308c42ba427de9eaaf5a54d7f2f6ae5 + checksum: 3392aa71f9c68c16022947959f7f842174e6b075b3baf58e185462411cd9b5a87557109a9225b1abaa2d7200a08b7cb9d7f21aa035f0d36b2c89e19b2a124e02 languageName: node linkType: hard @@ -2041,9 +2041,9 @@ __metadata: languageName: node linkType: hard -"@refinery/language-web@workspace:subprojects/language-web": +"@refinery/frontend@workspace:subprojects/frontend": version: 0.0.0-use.local - resolution: "@refinery/language-web@workspace:subprojects/language-web" + resolution: "@refinery/frontend@workspace:subprojects/frontend" dependencies: "@babel/core": ^7.16.0 "@babel/plugin-transform-runtime": ^7.16.4 @@ -2067,7 +2067,7 @@ __metadata: "@codemirror/search": ^0.19.4 "@codemirror/state": ^0.19.6 "@codemirror/view": ^0.19.29 - "@emotion/react": ^11.7.0 + "@emotion/react": ^11.7.1 "@emotion/styled": ^11.6.0 "@fontsource/jetbrains-mono": ^4.5.0 "@fontsource/roboto": ^4.5.1 @@ -2097,12 +2097,11 @@ __metadata: image-webpack-loader: ^8.0.1 loglevel: ^1.8.0 loglevel-plugin-prefix: ^0.8.4 - magic-comments-loader: ^1.4.1 mini-css-extract-plugin: ^2.4.5 mobx: ^6.3.8 mobx-react-lite: ^3.2.2 nanoid: ^3.1.30 - postcss: ^8.4.4 + postcss: ^8.4.5 postcss-scss: ^4.0.2 react: ^17.0.2 react-dom: ^17.0.2 @@ -7032,17 +7031,6 @@ __metadata: languageName: node linkType: hard -"magic-comments-loader@npm:^1.4.1": - version: 1.4.1 - resolution: "magic-comments-loader@npm:1.4.1" - dependencies: - loader-utils: ^2.0.0 - micromatch: ^4.0.4 - schema-utils: ^3.1.1 - checksum: 8e2c70ae7cebbe545b33087da7bb028133ffa58fb84dddf3fc844a465a93fb3f5dc0a8c0ff46279d0e2936da98170f0d06eff3ad8d27413124f2cc9ee2df4246 - languageName: node - linkType: hard - "make-dir@npm:^1.0.0, make-dir@npm:^1.2.0": version: 1.3.0 resolution: "make-dir@npm:1.3.0" @@ -8321,14 +8309,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.2.15, postcss@npm:^8.3.11, postcss@npm:^8.4.4": - version: 8.4.4 - resolution: "postcss@npm:8.4.4" +"postcss@npm:^8.2.15, postcss@npm:^8.3.11, postcss@npm:^8.4.5": + version: 8.4.5 + resolution: "postcss@npm:8.4.5" dependencies: nanoid: ^3.1.30 picocolors: ^1.0.0 source-map-js: ^1.0.1 - checksum: 6cf3fe0ecdf5a0d2aeb5e8404938c7eab968704e2e29dc5421e90b4014eb1975c1c0ad828425f2428807ef6e3fcfadd71f988ab55cb06c28ac2866f22403255b + checksum: b78abdd89c10f7b48f4bdcd376104a19d6e9c7495ab521729bdb3df315af6c211360e9f06887ad3bc0ab0f61a04b91d68ea11462997c79cced58b9ccd66fac07 languageName: node linkType: hard @@ -9769,7 +9757,7 @@ __metadata: languageName: node linkType: hard -"stylis@npm:^4.0.10, stylis@npm:^4.0.3": +"stylis@npm:4.0.13, stylis@npm:^4.0.3": version: 4.0.13 resolution: "stylis@npm:4.0.13" checksum: 8ea7a87028b6383c6a982231c4b5b6150031ce028e0fdaf7b2ace82253d28a8af50cc5a9da8a421d3c7c4441592f393086e332795add672aa4a825f0fe3713a3 -- cgit v1.2.3-70-g09d2