From fc7e9312d00e60171ed77c477ed91231d3dbfff9 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sun, 12 Dec 2021 17:48:47 +0100 Subject: build: move modules into subproject directory --- subprojects/language-web/.editorconfig | 5 + subprojects/language-web/.eslintrc.js | 40 +++ subprojects/language-web/.stylelintrc.js | 15 + subprojects/language-web/build.gradle | 147 +++++++++ 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 + .../refinery/language/web/CacheControlFilter.java | 52 +++ .../refinery/language/web/ProblemWebModule.java | 35 ++ .../refinery/language/web/ProblemWebSetup.java | 25 ++ .../language/web/ProblemWebSocketServlet.java | 29 ++ .../refinery/language/web/ServerLauncher.java | 192 +++++++++++ .../web/occurrences/ProblemOccurrencesService.java | 16 + .../language/web/xtext/server/PongResult.java | 44 +++ .../language/web/xtext/server/ResponseHandler.java | 8 + .../web/xtext/server/ResponseHandlerException.java | 14 + .../xtext/server/SubscribingServiceContext.java | 26 ++ .../web/xtext/server/TransactionExecutor.java | 180 ++++++++++ .../xtext/server/message/XtextWebErrorKind.java | 11 + .../server/message/XtextWebErrorResponse.java | 79 +++++ .../xtext/server/message/XtextWebOkResponse.java | 72 ++++ .../xtext/server/message/XtextWebPushMessage.java | 81 +++++ .../web/xtext/server/message/XtextWebRequest.java | 57 ++++ .../web/xtext/server/message/XtextWebResponse.java | 4 + .../xtext/server/push/PrecomputationListener.java | 15 + .../xtext/server/push/PushServiceDispatcher.java | 23 ++ .../web/xtext/server/push/PushWebDocument.java | 89 +++++ .../xtext/server/push/PushWebDocumentAccess.java | 68 ++++ .../xtext/server/push/PushWebDocumentProvider.java | 33 ++ .../web/xtext/servlet/SimpleServiceContext.java | 26 ++ .../language/web/xtext/servlet/SimpleSession.java | 35 ++ .../web/xtext/servlet/XtextStatusCode.java | 9 + .../language/web/xtext/servlet/XtextWebSocket.java | 133 ++++++++ .../web/xtext/servlet/XtextWebSocketServlet.java | 83 +++++ 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 +++++++ .../ProblemWebSocketServletIntegrationTest.java | 204 ++++++++++++ .../AwaitTerminationExecutorServiceProvider.java | 42 +++ .../web/tests/ProblemWebInjectorProvider.java | 47 +++ .../web/tests/RestartableCachedThreadPool.java | 109 +++++++ .../web/tests/WebSocketIntegrationTestClient.java | 98 ++++++ .../web/xtext/servlet/TransactionExecutorTest.java | 165 ++++++++++ subprojects/language-web/tsconfig.json | 18 + subprojects/language-web/tsconfig.sonar.json | 17 + subprojects/language-web/webpack.config.js | 232 +++++++++++++ 78 files changed, 5992 insertions(+) create mode 100644 subprojects/language-web/.editorconfig create mode 100644 subprojects/language-web/.eslintrc.js create mode 100644 subprojects/language-web/.stylelintrc.js create mode 100644 subprojects/language-web/build.gradle create mode 100644 subprojects/language-web/package.json create mode 100644 subprojects/language-web/src/main/css/index.scss create mode 100644 subprojects/language-web/src/main/css/themeVariables.module.scss create mode 100644 subprojects/language-web/src/main/css/themes.scss create mode 100644 subprojects/language-web/src/main/html/index.html create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java create mode 100644 subprojects/language-web/src/main/js/App.tsx create mode 100644 subprojects/language-web/src/main/js/RootStore.tsx create mode 100644 subprojects/language-web/src/main/js/editor/EditorArea.tsx create mode 100644 subprojects/language-web/src/main/js/editor/EditorButtons.tsx create mode 100644 subprojects/language-web/src/main/js/editor/EditorParent.ts create mode 100644 subprojects/language-web/src/main/js/editor/EditorStore.ts create mode 100644 subprojects/language-web/src/main/js/editor/GenerateButton.tsx create mode 100644 subprojects/language-web/src/main/js/editor/decorationSetExtension.ts create mode 100644 subprojects/language-web/src/main/js/editor/findOccurrences.ts create mode 100644 subprojects/language-web/src/main/js/editor/semanticHighlighting.ts create mode 100644 subprojects/language-web/src/main/js/global.d.ts create mode 100644 subprojects/language-web/src/main/js/index.tsx create mode 100644 subprojects/language-web/src/main/js/language/folding.ts create mode 100644 subprojects/language-web/src/main/js/language/indentation.ts create mode 100644 subprojects/language-web/src/main/js/language/problem.grammar create mode 100644 subprojects/language-web/src/main/js/language/problemLanguageSupport.ts create mode 100644 subprojects/language-web/src/main/js/language/props.ts create mode 100644 subprojects/language-web/src/main/js/theme/EditorTheme.ts create mode 100644 subprojects/language-web/src/main/js/theme/ThemeProvider.tsx create mode 100644 subprojects/language-web/src/main/js/theme/ThemeStore.ts create mode 100644 subprojects/language-web/src/main/js/utils/ConditionVariable.ts create mode 100644 subprojects/language-web/src/main/js/utils/PendingTask.ts create mode 100644 subprojects/language-web/src/main/js/utils/Timer.ts create mode 100644 subprojects/language-web/src/main/js/utils/logger.ts create mode 100644 subprojects/language-web/src/main/js/xtext/ContentAssistService.ts create mode 100644 subprojects/language-web/src/main/js/xtext/HighlightingService.ts create mode 100644 subprojects/language-web/src/main/js/xtext/OccurrencesService.ts create mode 100644 subprojects/language-web/src/main/js/xtext/UpdateService.ts create mode 100644 subprojects/language-web/src/main/js/xtext/ValidationService.ts create mode 100644 subprojects/language-web/src/main/js/xtext/XtextClient.ts create mode 100644 subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts create mode 100644 subprojects/language-web/src/main/js/xtext/xtextMessages.ts create mode 100644 subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java create mode 100644 subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java create mode 100644 subprojects/language-web/tsconfig.json create mode 100644 subprojects/language-web/tsconfig.sonar.json create mode 100644 subprojects/language-web/webpack.config.js (limited to 'subprojects/language-web') diff --git a/subprojects/language-web/.editorconfig b/subprojects/language-web/.editorconfig new file mode 100644 index 00000000..1b78e967 --- /dev/null +++ b/subprojects/language-web/.editorconfig @@ -0,0 +1,5 @@ +[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 new file mode 100644 index 00000000..b27feb0e --- /dev/null +++ b/subprojects/language-web/.eslintrc.js @@ -0,0 +1,40 @@ +// 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 new file mode 100644 index 00000000..7adf8f26 --- /dev/null +++ b/subprojects/language-web/.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/language-web/build.gradle b/subprojects/language-web/build.gradle new file mode 100644 index 00000000..a549288a --- /dev/null +++ b/subprojects/language-web/build.gradle @@ -0,0 +1,147 @@ +plugins { + id 'refinery-frontend-workspace' + id 'refinery-java-application' + id 'refinery-xtext-conventions' +} + +import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn + +dependencies { + implementation project(':refinery-language') + implementation project(':refinery-language-ide') + implementation libs.xtend.lib + implementation libs.xtext.web + implementation libs.jetty.server + implementation libs.jetty.servlet + implementation libs.jetty.websocket.server + implementation libs.slf4j.simple + implementation libs.slf4j.log4j + testImplementation testFixtures(project(':refinery-language')) + testImplementation libs.jetty.websocket.client +} + +def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage') + +for (taskName in ['compileJava', 'processResources']) { + tasks.named(taskName) { + dependsOn generateXtextLanguage + } +} + +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 + +tasks.named('jar') { + dependsOn assembleFrontend + from(productionResources) { + into 'webapp' + } +} + +tasks.named('shadowJar') { + dependsOn assembleFrontend + from(project.convention.getPlugin(JavaPluginConvention).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) { + into 'webapp' + } +} + +def jettyRun = tasks.register('jettyRun', JavaExec) { + dependsOn assembleFrontend + dependsOn sourceSets.main.runtimeClasspath + classpath = sourceSets.main.runtimeClasspath.filter{it.exists()} + mainClass = serverMainClass + standardInput = System.in + environment BASE_RESOURCE: productionResources + 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 new file mode 100644 index 00000000..5fa977d9 --- /dev/null +++ b/subprojects/language-web/package.json @@ -0,0 +1,103 @@ +{ + "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 new file mode 100644 index 00000000..ad876aaf --- /dev/null +++ b/subprojects/language-web/src/main/css/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/language-web/src/main/css/themeVariables.module.scss b/subprojects/language-web/src/main/css/themeVariables.module.scss new file mode 100644 index 00000000..85af4219 --- /dev/null +++ b/subprojects/language-web/src/main/css/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/language-web/src/main/css/themes.scss b/subprojects/language-web/src/main/css/themes.scss new file mode 100644 index 00000000..a30f1de3 --- /dev/null +++ b/subprojects/language-web/src/main/css/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/language-web/src/main/html/index.html b/subprojects/language-web/src/main/html/index.html new file mode 100644 index 00000000..f404aa8a --- /dev/null +++ b/subprojects/language-web/src/main/html/index.html @@ -0,0 +1,16 @@ + + + + + + Refinery + + + +
+ + diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java new file mode 100644 index 00000000..b13ae95d --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java @@ -0,0 +1,52 @@ +package tools.refinery.language.web; + +import java.io.IOException; +import java.time.Duration; +import java.util.regex.Pattern; + +import org.eclipse.jetty.http.HttpHeader; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CacheControlFilter implements Filter { + private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)"); + + private static final Duration EXPIRY = Duration.ofDays(365); + + private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable"; + + private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate"; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + // Nothing to initialize. + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) { + if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) { + httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE); + httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), + System.currentTimeMillis() + EXPIRY.toMillis()); + } else { + httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE); + httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0); + } + } + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // Nothing to dispose. + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java new file mode 100644 index 00000000..ec55036f --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java @@ -0,0 +1,35 @@ +/* + * generated by Xtext 2.25.0 + */ +package tools.refinery.language.web; + +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.model.IWebDocumentProvider; +import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; +import org.eclipse.xtext.web.server.occurrences.OccurrencesService; + +import tools.refinery.language.web.occurrences.ProblemOccurrencesService; +import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher; +import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess; +import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider; + +/** + * Use this class to register additional components to be used within the web application. + */ +public class ProblemWebModule extends AbstractProblemWebModule { + public Class bindIWebDocumentProvider() { + return PushWebDocumentProvider.class; + } + + public Class bindXtextWebDocumentAccess() { + return PushWebDocumentAccess.class; + } + + public Class bindXtextServiceDispatcher() { + return PushServiceDispatcher.class; + } + + public Class bindOccurrencesService() { + return ProblemOccurrencesService.class; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java new file mode 100644 index 00000000..4738bc80 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java @@ -0,0 +1,25 @@ +/* + * generated by Xtext 2.25.0 + */ +package tools.refinery.language.web; + +import org.eclipse.xtext.util.Modules2; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +import tools.refinery.language.ProblemRuntimeModule; +import tools.refinery.language.ProblemStandaloneSetup; +import tools.refinery.language.ide.ProblemIdeModule; + +/** + * Initialization support for running Xtext languages in web applications. + */ +public class ProblemWebSetup extends ProblemStandaloneSetup { + + @Override + public Injector createInjector() { + return Guice.createInjector(Modules2.mixin(new ProblemRuntimeModule(), new ProblemIdeModule(), new ProblemWebModule())); + } + +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java new file mode 100644 index 00000000..df67b521 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java @@ -0,0 +1,29 @@ +package tools.refinery.language.web; + +import org.eclipse.xtext.util.DisposableRegistry; + +import jakarta.servlet.ServletException; +import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; + +public class ProblemWebSocketServlet extends XtextWebSocketServlet { + + private static final long serialVersionUID = -7040955470384797008L; + + private transient DisposableRegistry disposableRegistry; + + @Override + public void init() throws ServletException { + super.init(); + var injector = new ProblemWebSetup().createInjectorAndDoEMFRegistration(); + this.disposableRegistry = injector.getInstance(DisposableRegistry.class); + } + + @Override + public void destroy() { + if (disposableRegistry != null) { + disposableRegistry.dispose(); + disposableRegistry = null; + } + super.destroy(); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java new file mode 100644 index 00000000..ffd903d0 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java @@ -0,0 +1,192 @@ +/* + * generated by Xtext 2.25.0 + */ +package tools.refinery.language.web; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.session.SessionHandler; +import org.eclipse.jetty.servlet.DefaultServlet; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.util.resource.Resource; +import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.SessionTrackingMode; +import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; + +public class ServerLauncher { + public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; + + public static final int DEFAULT_LISTEN_PORT = 1312; + + public static final int DEFAULT_PUBLIC_PORT = 443; + + public static final int HTTP_DEFAULT_PORT = 80; + + public static final int HTTPS_DEFAULT_PORT = 443; + + public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; + + private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); + + private final Server server; + + public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional allowedOrigins) { + server = new Server(bindAddress); + var handler = new ServletContextHandler(); + addSessionHandler(handler); + addProblemServlet(handler, allowedOrigins); + if (baseResource != null) { + handler.setBaseResource(baseResource); + handler.setWelcomeFiles(new String[] { "index.html" }); + addDefaultServlet(handler); + } + handler.addFilter(CacheControlFilter.class, "/*", EnumSet.of(DispatcherType.REQUEST)); + server.setHandler(handler); + } + + private void addSessionHandler(ServletContextHandler handler) { + var sessionHandler = new SessionHandler(); + sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); + handler.setSessionHandler(sessionHandler); + } + + private void addProblemServlet(ServletContextHandler handler, Optional allowedOrigins) { + var problemServletHolder = new ServletHolder(ProblemWebSocketServlet.class); + if (allowedOrigins.isEmpty()) { + LOG.warn("All WebSocket origins are allowed! This setting should not be used in production!"); + } else { + var allowedOriginsString = String.join(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR, + allowedOrigins.get()); + problemServletHolder.setInitParameter(XtextWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, + allowedOriginsString); + } + handler.addServlet(problemServletHolder, "/xtext-service"); + JettyWebSocketServletContainerInitializer.configure(handler, null); + } + + private void addDefaultServlet(ServletContextHandler handler) { + var defaultServletHolder = new ServletHolder(DefaultServlet.class); + var isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + // Avoid file locking on Windows: https://stackoverflow.com/a/4985717 + // See also the related Jetty ticket: + // https://github.com/eclipse/jetty.project/issues/2925 + defaultServletHolder.setInitParameter("useFileMappedBuffer", isWindows ? "false" : "true"); + handler.addServlet(defaultServletHolder, "/"); + } + + public void start() throws Exception { + server.start(); + LOG.info("Server started on {}", server.getURI()); + server.join(); + } + + public static void main(String[] args) { + try { + var bindAddress = getBindAddress(); + var baseResource = getBaseResource(); + var allowedOrigins = getAllowedOrigins(); + var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins); + serverLauncher.start(); + } catch (Exception exception) { + LOG.error("Fatal server error", exception); + System.exit(1); + } + } + + private static String getListenAddress() { + var listenAddress = System.getenv("LISTEN_ADDRESS"); + if (listenAddress == null) { + return DEFAULT_LISTEN_ADDRESS; + } + return listenAddress; + } + + private static int getListenPort() { + var portStr = System.getenv("LISTEN_PORT"); + if (portStr != null) { + return Integer.parseInt(portStr); + } + return DEFAULT_LISTEN_PORT; + } + + private static InetSocketAddress getBindAddress() { + var listenAddress = getListenAddress(); + var listenPort = getListenPort(); + return new InetSocketAddress(listenAddress, listenPort); + } + + private static Resource getBaseResource() throws IOException, URISyntaxException { + var baseResourceOverride = System.getenv("BASE_RESOURCE"); + if (baseResourceOverride != null) { + // If a user override is provided, use it. + return Resource.newResource(baseResourceOverride); + } + var indexUrlInJar = ServerLauncher.class.getResource("/webapp/index.html"); + if (indexUrlInJar != null) { + // If the app is packaged in the jar, serve it. + var webRootUri = URI.create(indexUrlInJar.toURI().toASCIIString().replaceFirst("/index.html$", "/")); + return Resource.newResource(webRootUri); + } + // Look for unpacked production artifacts (convenience for running from IDE). + var unpackedResourcePathComponents = new String[] { System.getProperty("user.dir"), "build", "webpack", + "production" }; + var unpackedResourceDir = new File(String.join(File.separator, unpackedResourcePathComponents)); + if (unpackedResourceDir.isDirectory()) { + return Resource.newResource(unpackedResourceDir); + } + // Fall back to just serving a 404. + return null; + } + + private static String getPublicHost() { + var publicHost = System.getenv("PUBLIC_HOST"); + if (publicHost != null) { + return publicHost.toLowerCase(); + } + return null; + } + + private static int getPublicPort() { + var portStr = System.getenv("PUBLIC_PORT"); + if (portStr != null) { + return Integer.parseInt(portStr); + } + return DEFAULT_LISTEN_PORT; + } + + private static Optional getAllowedOrigins() { + var allowedOrigins = System.getenv("ALLOWED_ORIGINS"); + if (allowedOrigins != null) { + return Optional.of(allowedOrigins.split(ALLOWED_ORIGINS_SEPARATOR)); + } + return getAllowedOriginsFromPublicHostAndPort(); + } + + private static Optional getAllowedOriginsFromPublicHostAndPort() { + var publicHost = getPublicHost(); + if (publicHost == null) { + return Optional.empty(); + } + int publicPort = getPublicPort(); + var scheme = publicPort == HTTPS_DEFAULT_PORT ? "https" : "http"; + var urlWithPort = String.format("%s://%s:%d", scheme, publicHost, publicPort); + if (publicPort == HTTPS_DEFAULT_PORT || publicPort == HTTP_DEFAULT_PORT) { + var urlWithoutPort = String.format("%s://%s", scheme, publicHost); + return Optional.of(new String[] { urlWithPort, urlWithoutPort }); + } + return Optional.of(new String[] { urlWithPort }); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java new file mode 100644 index 00000000..d32bbb54 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java @@ -0,0 +1,16 @@ +package tools.refinery.language.web.occurrences; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.web.server.occurrences.OccurrencesService; + +import com.google.inject.Singleton; + +import tools.refinery.language.model.problem.NamedElement; + +@Singleton +public class ProblemOccurrencesService extends OccurrencesService { + @Override + protected boolean filter(EObject element) { + return super.filter(element) && element instanceof NamedElement; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java new file mode 100644 index 00000000..fe510f51 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java @@ -0,0 +1,44 @@ +package tools.refinery.language.web.xtext.server; + +import java.util.Objects; + +import org.eclipse.xtext.web.server.IServiceResult; + +public class PongResult implements IServiceResult { + private String pong; + + public PongResult(String pong) { + super(); + this.pong = pong; + } + + public String getPong() { + return pong; + } + + public void setPong(String pong) { + this.pong = pong; + } + + @Override + public int hashCode() { + return Objects.hash(pong); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + PongResult other = (PongResult) obj; + return Objects.equals(pong, other.pong); + } + + @Override + public String toString() { + return "PongResult [pong=" + pong + "]"; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java new file mode 100644 index 00000000..2a85afe3 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java @@ -0,0 +1,8 @@ +package tools.refinery.language.web.xtext.server; + +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + +@FunctionalInterface +public interface ResponseHandler { + void onResponse(XtextWebResponse response) throws ResponseHandlerException; +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java new file mode 100644 index 00000000..34fcb546 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java @@ -0,0 +1,14 @@ +package tools.refinery.language.web.xtext.server; + +public class ResponseHandlerException extends Exception { + + private static final long serialVersionUID = 3589866922420268164L; + + public ResponseHandlerException(String message, Throwable cause) { + super(message, cause); + } + + public ResponseHandlerException(String message) { + super(message); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java new file mode 100644 index 00000000..78e00a9e --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java @@ -0,0 +1,26 @@ +package tools.refinery.language.web.xtext.server; + +import java.util.Set; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; + +import tools.refinery.language.web.xtext.server.push.PrecomputationListener; + +public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber) + implements IServiceContext { + @Override + public Set getParameterKeys() { + return delegate.getParameterKeys(); + } + + @Override + public String getParameter(String key) { + return delegate.getParameter(key); + } + + @Override + public ISession getSession() { + return delegate.getSession(); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java new file mode 100644 index 00000000..0b417b06 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java @@ -0,0 +1,180 @@ +package tools.refinery.language.web.xtext.server; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.util.IDisposable; +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.web.server.InvalidRequestException; +import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Strings; +import com.google.inject.Injector; + +import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind; +import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.push.PrecomputationListener; +import tools.refinery.language.web.xtext.server.push.PushWebDocument; +import tools.refinery.language.web.xtext.servlet.SimpleServiceContext; + +public class TransactionExecutor implements IDisposable, PrecomputationListener { + private static final Logger LOG = LoggerFactory.getLogger(TransactionExecutor.class); + + private final ISession session; + + private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + private final Map> subscriptions = new HashMap<>(); + + private ResponseHandler responseHandler; + + private Object callPendingLock = new Object(); + + private boolean callPending; + + private List pendingPushMessages = new ArrayList<>(); + + public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this.session = session; + this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; + } + + public void setResponseHandler(ResponseHandler responseHandler) { + this.responseHandler = responseHandler; + } + + public void handleRequest(XtextWebRequest request) throws ResponseHandlerException { + var serviceContext = new SimpleServiceContext(session, request.getRequestData()); + var ping = serviceContext.getParameter("ping"); + if (ping != null) { + responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping))); + return; + } + synchronized (callPendingLock) { + if (callPending) { + LOG.error("Reentrant request detected"); + } + if (!pendingPushMessages.isEmpty()) { + LOG.error("{} push messages got stuck without a pending request", pendingPushMessages.size()); + } + callPending = true; + } + try { + var injector = getInjector(serviceContext); + var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); + var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this)); + var serviceResult = service.getService().apply(); + responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult)); + } catch (InvalidRequestException e) { + responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e)); + } catch (RuntimeException e) { + responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e)); + } finally { + synchronized (callPendingLock) { + for (var message : pendingPushMessages) { + try { + responseHandler.onResponse(message); + } catch (ResponseHandlerException | RuntimeException e) { + LOG.error("Error while flushing push message", e); + } + } + pendingPushMessages.clear(); + callPending = false; + } + } + } + + @Override + public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, + IServiceResult serviceResult) throws ResponseHandlerException { + var message = new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult); + synchronized (callPendingLock) { + // If we're currently responding to a call we must delay any push messages until + // the reply is sent, because push messages relating to the new state id must be + // sent after the response with the new state id so that the client knows about + // the new state when it receives the push message. + if (callPending) { + pendingPushMessages.add(message); + } else { + responseHandler.onResponse(message); + } + } + } + + @Override + public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { + PushWebDocument previousDocument = null; + var previousSubscription = subscriptions.get(resourceId); + if (previousSubscription != null) { + previousDocument = previousSubscription.get(); + } + if (previousDocument == document) { + return; + } + if (previousDocument != null) { + previousDocument.removePrecomputationListener(this); + } + subscriptions.put(resourceId, new WeakReference<>(document)); + } + + /** + * Get the injector to satisfy the request in the {@code serviceContext}. + * + * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. + * + * @param serviceContext the Xtext service context of the request + * @return the injector for the Xtext language in the request + * @throws UnknownLanguageException if the Xtext language cannot be determined + */ + protected Injector getInjector(IServiceContext context) { + IResourceServiceProvider resourceServiceProvider = null; + var resourceName = context.getParameter("resource"); + if (resourceName == null) { + resourceName = ""; + } + var emfURI = URI.createURI(resourceName); + var contentType = context.getParameter("contentType"); + if (Strings.isNullOrEmpty(contentType)) { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); + if (resourceServiceProvider == null) { + if (emfURI.toString().isEmpty()) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); + } else { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for resource " + emfURI + "."); + } + } + } else { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); + if (resourceServiceProvider == null) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for contentType " + contentType + "."); + } + } + return resourceServiceProvider.get(Injector.class); + } + + @Override + public void dispose() { + for (var subscription : subscriptions.values()) { + var document = subscription.get(); + if (document != null) { + document.removePrecomputationListener(this); + } + } + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java new file mode 100644 index 00000000..f74bae74 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java @@ -0,0 +1,11 @@ +package tools.refinery.language.web.xtext.server.message; + +import com.google.gson.annotations.SerializedName; + +public enum XtextWebErrorKind { + @SerializedName("request") + REQUEST_ERROR, + + @SerializedName("server") + SERVER_ERROR, +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java new file mode 100644 index 00000000..01d78c31 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java @@ -0,0 +1,79 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebErrorResponse implements XtextWebResponse { + private String id; + + @SerializedName("error") + private XtextWebErrorKind errorKind; + + @SerializedName("message") + private String errorMessage; + + public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) { + super(); + this.id = id; + this.errorKind = errorKind; + this.errorMessage = errorMessage; + } + + public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, + String errorMessage) { + this(request.getId(), errorKind, errorMessage); + } + + public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) { + this(request, errorKind, t.getMessage()); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public XtextWebErrorKind getErrorKind() { + return errorKind; + } + + public void setErrorKind(XtextWebErrorKind errorKind) { + this.errorKind = errorKind; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public int hashCode() { + return Objects.hash(errorKind, errorMessage, id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebErrorResponse other = (XtextWebErrorResponse) obj; + return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) + && Objects.equals(id, other.id); + } + + @Override + public String toString() { + return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage + + "]"; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java new file mode 100644 index 00000000..8af27247 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java @@ -0,0 +1,72 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.IUnwrappableServiceResult; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebOkResponse implements XtextWebResponse { + private String id; + + @SerializedName("response") + private Object responseData; + + public XtextWebOkResponse(String id, Object responseData) { + super(); + this.id = id; + this.responseData = responseData; + } + + public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) { + this(request.getId(), maybeUnwrap(result)); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Object getResponseData() { + return responseData; + } + + public void setResponseData(Object responseData) { + this.responseData = responseData; + } + + @Override + public int hashCode() { + return Objects.hash(id, responseData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebOkResponse other = (XtextWebOkResponse) obj; + return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData); + } + + @Override + public String toString() { + return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]"; + } + + private static Object maybeUnwrap(IServiceResult result) { + if (result instanceof IUnwrappableServiceResult unwrappableServiceResult + && unwrappableServiceResult.getContent() != null) { + return unwrappableServiceResult.getContent(); + } else { + return result; + } + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java new file mode 100644 index 00000000..c9432e1c --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java @@ -0,0 +1,81 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebPushMessage implements XtextWebResponse { + @SerializedName("resource") + private String resourceId; + + private String stateId; + + private String service; + + @SerializedName("push") + private Object pushData; + + public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) { + super(); + this.resourceId = resourceId; + this.stateId = stateId; + this.service = service; + this.pushData = pushData; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getStateId() { + return stateId; + } + + public void setStateId(String stateId) { + this.stateId = stateId; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public Object getPushData() { + return pushData; + } + + public void setPushData(Object pushData) { + this.pushData = pushData; + } + + @Override + public int hashCode() { + return Objects.hash(pushData, resourceId, service, stateId); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebPushMessage other = (XtextWebPushMessage) obj; + return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId) + && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId); + } + + @Override + public String toString() { + return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service + + ", pushData=" + pushData + "]"; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java new file mode 100644 index 00000000..959749f8 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java @@ -0,0 +1,57 @@ +package tools.refinery.language.web.xtext.server.message; + +import java.util.Map; +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public class XtextWebRequest { + private String id; + + @SerializedName("request") + private Map requestData; + + public XtextWebRequest(String id, Map requestData) { + super(); + this.id = id; + this.requestData = requestData; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getRequestData() { + return requestData; + } + + public void setRequestData(Map requestData) { + this.requestData = requestData; + } + + @Override + public int hashCode() { + return Objects.hash(id, requestData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebRequest other = (XtextWebRequest) obj; + return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData); + } + + @Override + public String toString() { + return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]"; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java new file mode 100644 index 00000000..3bd13047 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java @@ -0,0 +1,4 @@ +package tools.refinery.language.web.xtext.server.message; + +public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage { +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java new file mode 100644 index 00000000..79a284db --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java @@ -0,0 +1,15 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceResult; + +import tools.refinery.language.web.xtext.server.ResponseHandlerException; + +@FunctionalInterface +public interface PrecomputationListener { + void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult) + throws ResponseHandlerException; + + default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { + // Nothing to handle by default. + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java new file mode 100644 index 00000000..c7b8108d --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java @@ -0,0 +1,23 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.model.XtextWebDocument; + +import com.google.inject.Singleton; + +import tools.refinery.language.web.xtext.server.SubscribingServiceContext; + +@Singleton +public class PushServiceDispatcher extends XtextServiceDispatcher { + + @Override + protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) { + var document = super.getFullTextDocument(fullText, resourceId, context); + if (document instanceof PushWebDocument pushWebDocument + && context instanceof SubscribingServiceContext subscribingContext) { + pushWebDocument.addPrecomputationListener(subscribingContext.subscriber()); + } + return document; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java new file mode 100644 index 00000000..906b9e30 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java @@ -0,0 +1,89 @@ +package tools.refinery.language.web.xtext.server.push; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.model.AbstractCachedService; +import org.eclipse.xtext.web.server.model.DocumentSynchronizer; +import org.eclipse.xtext.web.server.model.XtextWebDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableList; + +import tools.refinery.language.web.xtext.server.ResponseHandlerException; + +public class PushWebDocument extends XtextWebDocument { + private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class); + + private final List precomputationListeners = new ArrayList<>(); + + private final Map, IServiceResult> precomputedServices = new HashMap<>(); + + public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) { + super(resourceId, synchronizer); + if (resourceId == null) { + throw new IllegalArgumentException("resourceId must not be null"); + } + } + + public boolean addPrecomputationListener(PrecomputationListener listener) { + synchronized (precomputationListeners) { + if (precomputationListeners.contains(listener)) { + return false; + } + precomputationListeners.add(listener); + listener.onSubscribeToPrecomputationEvents(getResourceId(), this); + return true; + } + } + + public boolean removePrecomputationListener(PrecomputationListener listener) { + synchronized (precomputationListeners) { + return precomputationListeners.remove(listener); + } + } + + public void precomputeServiceResult(AbstractCachedService service, String serviceName, + CancelIndicator cancelIndicator, boolean logCacheMiss) { + var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss); + if (result == null) { + LOG.error("{} service returned null result", serviceName); + return; + } + var serviceClass = service.getClass(); + var previousResult = precomputedServices.get(serviceClass); + if (previousResult != null && previousResult.equals(result)) { + return; + } + precomputedServices.put(serviceClass, result); + notifyPrecomputationListeners(serviceName, result); + } + + private void notifyPrecomputationListeners(String serviceName, T result) { + var resourceId = getResourceId(); + var stateId = getStateId(); + List copyOfListeners; + synchronized (precomputationListeners) { + copyOfListeners = ImmutableList.copyOf(precomputationListeners); + } + var toRemove = new ArrayList(); + for (var listener : copyOfListeners) { + try { + listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result); + } catch (ResponseHandlerException e) { + LOG.error("Delivering precomputation push message failed", e); + toRemove.add(listener); + } + } + if (!toRemove.isEmpty()) { + synchronized (precomputationListeners) { + precomputationListeners.removeAll(toRemove); + } + } + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java new file mode 100644 index 00000000..b3666a86 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java @@ -0,0 +1,68 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.service.OperationCanceledManager; +import org.eclipse.xtext.util.CancelIndicator; +import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.model.AbstractCachedService; +import org.eclipse.xtext.web.server.model.IXtextWebDocument; +import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; +import org.eclipse.xtext.web.server.model.XtextWebDocument; +import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; +import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService; +import org.eclipse.xtext.web.server.validation.ValidationService; + +import com.google.inject.Inject; + +public class PushWebDocumentAccess extends XtextWebDocumentAccess { + + @Inject + private PrecomputedServiceRegistry preComputedServiceRegistry; + + @Inject + private OperationCanceledManager operationCanceledManager; + + private PushWebDocument pushDocument; + + @Override + protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) { + super.init(document, requiredStateId, skipAsyncWork); + if (document instanceof PushWebDocument newPushDocument) { + pushDocument = newPushDocument; + } + } + + @Override + protected void performPrecomputation(CancelIndicator cancelIndicator) { + if (pushDocument == null) { + super.performPrecomputation(cancelIndicator); + return; + } + for (AbstractCachedService service : preComputedServiceRegistry + .getPrecomputedServices()) { + operationCanceledManager.checkCanceled(cancelIndicator); + precomputeServiceResult(service, false); + } + } + + protected void precomputeServiceResult(AbstractCachedService service, boolean logCacheMiss) { + var serviceName = getPrecomputedServiceName(service); + readOnly(new CancelableUnitOfWork() { + @Override + public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception { + pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss); + return null; + } + }); + } + + protected String getPrecomputedServiceName(AbstractCachedService service) { + if (service instanceof ValidationService) { + return "validate"; + } + if (service instanceof HighlightingService) { + return "highlight"; + } + throw new IllegalArgumentException("Unknown precomputed service: " + service); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java new file mode 100644 index 00000000..fc45f74a --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java @@ -0,0 +1,33 @@ +package tools.refinery.language.web.xtext.server.push; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.model.DocumentSynchronizer; +import org.eclipse.xtext.web.server.model.IWebDocumentProvider; +import org.eclipse.xtext.web.server.model.XtextWebDocument; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; + +/** + * Based on + * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}. + * + * @author Kristóf Marussy + */ +@Singleton +public class PushWebDocumentProvider implements IWebDocumentProvider { + @Inject + private Provider synchronizerProvider; + + @Override + public XtextWebDocument get(String resourceId, IServiceContext serviceContext) { + if (resourceId == null) { + return new XtextWebDocument(resourceId, synchronizerProvider.get()); + } else { + // We only need to send push messages if a resourceId is specified. + return new PushWebDocument(resourceId, + serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get())); + } + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java new file mode 100644 index 00000000..43e37160 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java @@ -0,0 +1,26 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; + +import com.google.common.collect.ImmutableSet; + +public record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { + @Override + public Set getParameterKeys() { + return ImmutableSet.copyOf(parameters.keySet()); + } + + @Override + public String getParameter(String key) { + return parameters.get(key); + } + + @Override + public ISession getSession() { + return session; + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java new file mode 100644 index 00000000..09c055a2 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java @@ -0,0 +1,35 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.xbase.lib.Functions.Function0; + +public class SimpleSession implements ISession { + private Map map = new HashMap<>(); + + @Override + public T get(Object key) { + @SuppressWarnings("unchecked") + var value = (T) map.get(key); + return value; + } + + @Override + public T get(Object key, Function0 factory) { + @SuppressWarnings("unchecked") + var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply()); + return value; + } + + @Override + public void put(Object key, Object value) { + map.put(key, value); + } + + @Override + public void remove(Object key) { + map.remove(key); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java new file mode 100644 index 00000000..0cd229e8 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java @@ -0,0 +1,9 @@ +package tools.refinery.language.web.xtext.servlet; + +public final class XtextStatusCode { + public static final int INVALID_JSON = 4007; + + private XtextStatusCode() { + throw new IllegalStateException("This is a static utility class and should not be instantiated directly"); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java new file mode 100644 index 00000000..fd41f213 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java @@ -0,0 +1,133 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; +import java.io.Reader; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.web.server.ISession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; + +import tools.refinery.language.web.xtext.server.ResponseHandler; +import tools.refinery.language.web.xtext.server.ResponseHandlerException; +import tools.refinery.language.web.xtext.server.TransactionExecutor; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + +@WebSocket +public class XtextWebSocket implements WriteCallback, ResponseHandler { + private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); + + private final Gson gson = new Gson(); + + private final TransactionExecutor executor; + + private Session webSocketSession; + + public XtextWebSocket(TransactionExecutor executor) { + this.executor = executor; + executor.setResponseHandler(this); + } + + public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this(new TransactionExecutor(session, resourceServiceProviderRegistry)); + } + + @OnWebSocketConnect + public void onConnect(Session webSocketSession) { + if (this.webSocketSession != null) { + LOG.error("Websocket session onConnect when already connected"); + return; + } + LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); + this.webSocketSession = webSocketSession; + } + + @OnWebSocketClose + public void onClose(int statusCode, String reason) { + executor.dispose(); + if (webSocketSession == null) { + return; + } + if (statusCode == StatusCode.NORMAL || statusCode == StatusCode.SHUTDOWN) { + LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); + } else { + LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, + reason); + } + webSocketSession = null; + } + + @OnWebSocketError + public void onError(Throwable error) { + if (webSocketSession == null) { + return; + } + LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); + } + + @OnWebSocketMessage + public void onMessage(Reader reader) { + if (webSocketSession == null) { + LOG.error("Trying to receive message when websocket is disconnected"); + return; + } + XtextWebRequest request; + try { + request = gson.fromJson(reader, XtextWebRequest.class); + } catch (JsonIOException e) { + LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); + } + return; + } catch (JsonParseException e) { + LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); + webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); + return; + } + try { + executor.handleRequest(request); + } catch (ResponseHandlerException e) { + LOG.warn("Cannot write websocket response", e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response"); + } + } + } + + @Override + public void onResponse(XtextWebResponse response) throws ResponseHandlerException { + if (webSocketSession == null) { + throw new ResponseHandlerException("Trying to send message when websocket is disconnected"); + } + var responseString = gson.toJson(response); + try { + webSocketSession.getRemote().sendPartialString(responseString, true, this); + } catch (IOException e) { + throw new ResponseHandlerException( + "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); + } + } + + @Override + public void writeFailed(Throwable x) { + if (webSocketSession == null) { + LOG.error("Cannot complete async write to disconnected websocket", x); + return; + } + LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java new file mode 100644 index 00000000..942ca380 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java @@ -0,0 +1,83 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; +import java.time.Duration; +import java.util.Set; + +import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; +import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; +import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; +import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; +import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; + +public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator { + + private static final long serialVersionUID = -3772740838165122685L; + + public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; + + public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin"; + + public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1"; + + /** + * Maximum message size should be large enough to upload a full model file. + */ + private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; + + private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30); + + private transient Logger log = LoggerFactory.getLogger(getClass()); + + private transient Set allowedOrigins = null; + + @Override + public void init(ServletConfig config) throws ServletException { + var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); + if (allowedOriginsStr == null) { + log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); + } else { + allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); + log.info("Allowed origins: {}", allowedOrigins); + } + super.init(config); + } + + @Override + protected void configure(JettyWebSocketServletFactory factory) { + factory.setMaxFrameSize(MAX_FRAME_SIZE); + factory.setIdleTimeout(IDLE_TIMEOUT); + factory.addMapping("/", this); + } + + @Override + public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { + if (allowedOrigins != null) { + var origin = req.getOrigin(); + if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) { + log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); + try { + resp.sendForbidden("Origin not allowed"); + } catch (IOException e) { + log.error("Cannot send forbidden origin error", e); + } + return null; + } + } + if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) { + resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1); + } else { + log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(), + req.getRemoteSocketAddress()); + resp.setAcceptedSubProtocol(null); + } + var session = new SimpleSession(); + return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); + } +} diff --git a/subprojects/language-web/src/main/js/App.tsx b/subprojects/language-web/src/main/js/App.tsx new file mode 100644 index 00000000..54f92f9a --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/RootStore.tsx b/subprojects/language-web/src/main/js/RootStore.tsx new file mode 100644 index 00000000..baf0b61e --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/EditorArea.tsx b/subprojects/language-web/src/main/js/editor/EditorArea.tsx new file mode 100644 index 00000000..dba20f6e --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/EditorButtons.tsx b/subprojects/language-web/src/main/js/editor/EditorButtons.tsx new file mode 100644 index 00000000..150aa00d --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/EditorParent.ts b/subprojects/language-web/src/main/js/editor/EditorParent.ts new file mode 100644 index 00000000..94ca24ea --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/EditorStore.ts b/subprojects/language-web/src/main/js/editor/EditorStore.ts new file mode 100644 index 00000000..5760de28 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/GenerateButton.tsx b/subprojects/language-web/src/main/js/editor/GenerateButton.tsx new file mode 100644 index 00000000..3834cec4 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/decorationSetExtension.ts b/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts new file mode 100644 index 00000000..2d630c20 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/findOccurrences.ts b/subprojects/language-web/src/main/js/editor/findOccurrences.ts new file mode 100644 index 00000000..92102746 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/editor/semanticHighlighting.ts b/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts new file mode 100644 index 00000000..2aed421b --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/global.d.ts b/subprojects/language-web/src/main/js/global.d.ts new file mode 100644 index 00000000..0533a46e --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/index.tsx b/subprojects/language-web/src/main/js/index.tsx new file mode 100644 index 00000000..d368c9ba --- /dev/null +++ b/subprojects/language-web/src/main/js/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 '../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 new file mode 100644 index 00000000..5d51f796 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/language/indentation.ts b/subprojects/language-web/src/main/js/language/indentation.ts new file mode 100644 index 00000000..6d36ed3b --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/language/problem.grammar b/subprojects/language-web/src/main/js/language/problem.grammar new file mode 100644 index 00000000..bccc2e31 --- /dev/null +++ b/subprojects/language-web/src/main/js/language/problem.grammar @@ -0,0 +1,149 @@ +@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 new file mode 100644 index 00000000..6508a2c0 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/language/props.ts b/subprojects/language-web/src/main/js/language/props.ts new file mode 100644 index 00000000..8e488bf5 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/theme/EditorTheme.ts b/subprojects/language-web/src/main/js/theme/EditorTheme.ts new file mode 100644 index 00000000..1b0dd5de --- /dev/null +++ b/subprojects/language-web/src/main/js/theme/EditorTheme.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000..f5b50be1 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/theme/ThemeStore.ts b/subprojects/language-web/src/main/js/theme/ThemeStore.ts new file mode 100644 index 00000000..ffaf6dde --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/utils/ConditionVariable.ts b/subprojects/language-web/src/main/js/utils/ConditionVariable.ts new file mode 100644 index 00000000..0910dfa6 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/utils/PendingTask.ts b/subprojects/language-web/src/main/js/utils/PendingTask.ts new file mode 100644 index 00000000..51b79fb0 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/utils/Timer.ts b/subprojects/language-web/src/main/js/utils/Timer.ts new file mode 100644 index 00000000..8f653070 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/utils/logger.ts b/subprojects/language-web/src/main/js/utils/logger.ts new file mode 100644 index 00000000..306d122c --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/ContentAssistService.ts b/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/HighlightingService.ts b/subprojects/language-web/src/main/js/xtext/HighlightingService.ts new file mode 100644 index 00000000..dfbb4a19 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/OccurrencesService.ts b/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts new file mode 100644 index 00000000..bc865537 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/UpdateService.ts b/subprojects/language-web/src/main/js/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/ValidationService.ts b/subprojects/language-web/src/main/js/xtext/ValidationService.ts new file mode 100644 index 00000000..ff7d3700 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/XtextClient.ts b/subprojects/language-web/src/main/js/xtext/XtextClient.ts new file mode 100644 index 00000000..0898e725 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..2ce20a54 --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/xtextMessages.ts b/subprojects/language-web/src/main/js/xtext/xtextMessages.ts new file mode 100644 index 00000000..c4305fcf --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/main/js/xtext/xtextServiceResults.ts b/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..f79b059c --- /dev/null +++ b/subprojects/language-web/src/main/js/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/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java new file mode 100644 index 00000000..a26ce040 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java @@ -0,0 +1,204 @@ +package tools.refinery.language.web; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.api.exceptions.UpgradeException; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; +import org.eclipse.xtext.testing.GlobalRegistries; +import org.eclipse.xtext.testing.GlobalRegistries.GlobalStateMemento; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import tools.refinery.language.web.tests.WebSocketIntegrationTestClient; +import tools.refinery.language.web.xtext.servlet.XtextStatusCode; +import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; + +class ProblemWebSocketServletIntegrationTest { + private static int SERVER_PORT = 28080; + + private static String SERVLET_URI = "/xtext-service"; + + private GlobalStateMemento stateBeforeInjectorCreation; + + private Server server; + + private WebSocketClient client; + + @BeforeEach + void beforeEach() throws Exception { + stateBeforeInjectorCreation = GlobalRegistries.makeCopyOfGlobalState(); + client = new WebSocketClient(); + client.start(); + } + + @AfterEach + void afterEach() throws Exception { + client.stop(); + client = null; + if (server != null) { + server.stop(); + server = null; + } + stateBeforeInjectorCreation.restoreGlobalState(); + stateBeforeInjectorCreation = null; + } + + @Test + void updateTest() { + startServer(null); + var clientSocket = new UpdateTestClient(); + var session = connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1); + assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(), + equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1)); + clientSocket.waitForTestResult(); + assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL)); + var responses = clientSocket.getResponses(); + assertThat(responses, hasSize(5)); + assertThat(responses.get(0), equalTo("{\"id\":\"foo\",\"response\":{\"stateId\":\"-80000000\"}}")); + assertThat(responses.get(1), startsWith( + "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"highlight\",\"push\":{\"regions\":[")); + assertThat(responses.get(2), equalTo( + "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"validate\",\"push\":{\"issues\":[]}}")); + assertThat(responses.get(3), equalTo("{\"id\":\"bar\",\"response\":{\"stateId\":\"-7fffffff\"}}")); + assertThat(responses.get(4), startsWith( + "{\"resource\":\"test.problem\",\"stateId\":\"-7fffffff\",\"service\":\"highlight\",\"push\":{\"regions\":[")); + } + + @WebSocket + public static class UpdateTestClient extends WebSocketIntegrationTestClient { + @Override + protected void arrange(Session session, int responsesReceived) throws IOException { + switch (responsesReceived) { + case 0 -> session.getRemote().sendString( + "{\"id\":\"foo\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"fullText\":\"class Person.\n\"}}"); + case 3 -> session.getRemote().sendString( + "{\"id\":\"bar\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"requiredStateId\":\"-80000000\",\"deltaText\":\"indiv q.\nnode(q).\n\",\"deltaOffset\":\"0\",\"deltaReplaceLength\":\"0\"}}"); + case 5 -> session.close(); + } + } + } + + @Test + void badSubProtocolTest() { + startServer(null); + var clientSocket = new CloseImmediatelyTestClient(); + var session = connect(clientSocket, null, ""); + assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(), equalTo(null)); + clientSocket.waitForTestResult(); + assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL)); + } + + @WebSocket + public static class CloseImmediatelyTestClient extends WebSocketIntegrationTestClient { + @Override + protected void arrange(Session session, int responsesReceived) throws IOException { + session.close(); + } + } + + @Test + void subProtocolNegotiationTest() { + startServer(null); + var clientSocket = new CloseImmediatelyTestClient(); + var session = connect(clientSocket, null, "", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1); + assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(), + equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1)); + clientSocket.waitForTestResult(); + assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL)); + } + + @Test + void invalidJsonTest() { + startServer(null); + var clientSocket = new InvalidJsonTestClient(); + connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1); + clientSocket.waitForTestResult(); + assertThat(clientSocket.getCloseStatusCode(), equalTo(XtextStatusCode.INVALID_JSON)); + } + + @WebSocket + public static class InvalidJsonTestClient extends WebSocketIntegrationTestClient { + @Override + protected void arrange(Session session, int responsesReceived) throws IOException { + session.getRemote().sendString(""); + } + } + + @ParameterizedTest(name = "Origin: {0}") + @ValueSource(strings = { "https://refinery.example", "https://refinery.example:443", "HTTPS://REFINERY.EXAMPLE" }) + void validOriginTest(String origin) { + startServer("https://refinery.example;https://refinery.example:443"); + var clientSocket = new CloseImmediatelyTestClient(); + connect(clientSocket, origin, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1); + clientSocket.waitForTestResult(); + assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL)); + } + + @Test + void invalidOriginTest() { + startServer("https://refinery.example;https://refinery.example:443"); + var clientSocket = new CloseImmediatelyTestClient(); + var exception = assertThrows(CompletionException.class, + () -> connect(clientSocket, "https://invalid.example", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1)); + var innerException = exception.getCause(); + assertThat(innerException, instanceOf(UpgradeException.class)); + assertThat(((UpgradeException) innerException).getResponseStatusCode(), equalTo(HttpStatus.FORBIDDEN_403)); + } + + private void startServer(String allowedOrigins) { + server = new Server(new InetSocketAddress(SERVER_PORT)); + var handler = new ServletContextHandler(); + var holder = new ServletHolder(ProblemWebSocketServlet.class); + if (allowedOrigins != null) { + holder.setInitParameter(ProblemWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, allowedOrigins); + } + handler.addServlet(holder, SERVLET_URI); + JettyWebSocketServletContainerInitializer.configure(handler, null); + server.setHandler(handler); + try { + server.start(); + } catch (Exception e) { + throw new RuntimeException("Failed to start websocket server"); + } + } + + private Session connect(Object webSocketClient, String origin, String... subProtocols) { + var upgradeRequest = new ClientUpgradeRequest(); + if (origin != null) { + upgradeRequest.setHeader(HttpHeader.ORIGIN.name(), origin); + } + upgradeRequest.setSubProtocols(subProtocols); + CompletableFuture sessionFuture; + try { + sessionFuture = client.connect(webSocketClient, URI.create("ws://localhost:" + SERVER_PORT + SERVLET_URI), + upgradeRequest); + } catch (IOException e) { + throw new AssertionError("Unexpected exception while connection to websocket", e); + } + return sessionFuture.join(); + } +} diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java new file mode 100644 index 00000000..b70d0ed5 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java @@ -0,0 +1,42 @@ +package tools.refinery.language.web.tests; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; + +import org.eclipse.xtext.ide.ExecutorServiceProvider; + +import com.google.inject.Singleton; + +@Singleton +public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider { + private List servicesToShutDown = new ArrayList<>(); + + @Override + protected ExecutorService createInstance(String key) { + var instance = new RestartableCachedThreadPool(); + synchronized (servicesToShutDown) { + servicesToShutDown.add(instance); + } + return instance; + } + + public void waitForAllTasksToFinish() { + synchronized (servicesToShutDown) { + for (var executorService : servicesToShutDown) { + executorService.waitForAllTasksToFinish(); + } + } + } + + @Override + public void dispose() { + super.dispose(); + synchronized (servicesToShutDown) { + for (var executorService : servicesToShutDown) { + executorService.waitForTermination(); + } + servicesToShutDown.clear(); + } + } +} diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java new file mode 100644 index 00000000..43c12faa --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java @@ -0,0 +1,47 @@ +package tools.refinery.language.web.tests; + +import org.eclipse.xtext.ide.ExecutorServiceProvider; +import org.eclipse.xtext.util.DisposableRegistry; +import org.eclipse.xtext.util.Modules2; + +import com.google.inject.Guice; +import com.google.inject.Injector; + +import tools.refinery.language.ide.ProblemIdeModule; +import tools.refinery.language.tests.ProblemInjectorProvider; +import tools.refinery.language.web.ProblemWebModule; +import tools.refinery.language.web.ProblemWebSetup; + +public class ProblemWebInjectorProvider extends ProblemInjectorProvider { + + protected Injector internalCreateInjector() { + return new ProblemWebSetup() { + @Override + public Injector createInjector() { + return Guice.createInjector( + Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule())); + } + }.createInjectorAndDoEMFRegistration(); + } + + protected ProblemWebModule createWebModule() { + // Await termination of the executor service to avoid race conditions between + // the tasks in the service and the {@link + // org.eclipse.xtext.testing.extensions.InjectionExtension}. + return new ProblemWebModule() { + @SuppressWarnings("unused") + public Class bindExecutorServiceProvider() { + return AwaitTerminationExecutorServiceProvider.class; + } + }; + } + + @Override + public void restoreRegistry() { + // Also make sure to dispose any IDisposable instances (that may depend on the + // global state) created by Xtext before restoring the global state. + var disposableRegistry = getInjector().getInstance(DisposableRegistry.class); + disposableRegistry.dispose(); + super.restoreRegistry(); + } +} diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java new file mode 100644 index 00000000..1468273d --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java @@ -0,0 +1,109 @@ +package tools.refinery.language.web.tests; + +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RestartableCachedThreadPool implements ExecutorService { + private static final Logger LOG = LoggerFactory.getLogger(RestartableCachedThreadPool.class); + + private ExecutorService delegate; + + public RestartableCachedThreadPool() { + delegate = createExecutorService(); + } + + public void waitForAllTasksToFinish() { + delegate.shutdown(); + waitForTermination(); + delegate = createExecutorService(); + } + + public void waitForTermination() { + try { + delegate.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + LOG.warn("Interrupted while waiting for delegate executor to stop", e); + } + } + + protected ExecutorService createExecutorService() { + return Executors.newCachedThreadPool(); + } + + @Override + public boolean awaitTermination(long arg0, TimeUnit arg1) throws InterruptedException { + return delegate.awaitTermination(arg0, arg1); + } + + @Override + public void execute(Runnable arg0) { + delegate.execute(arg0); + } + + @Override + public List> invokeAll(Collection> arg0, long arg1, TimeUnit arg2) + throws InterruptedException { + return delegate.invokeAll(arg0, arg1, arg2); + } + + @Override + public List> invokeAll(Collection> arg0) throws InterruptedException { + return delegate.invokeAll(arg0); + } + + @Override + public T invokeAny(Collection> arg0, long arg1, TimeUnit arg2) + throws InterruptedException, ExecutionException, TimeoutException { + return delegate.invokeAny(arg0, arg1, arg2); + } + + @Override + public T invokeAny(Collection> arg0) throws InterruptedException, ExecutionException { + return delegate.invokeAny(arg0); + } + + @Override + public boolean isShutdown() { + return delegate.isShutdown(); + } + + @Override + public boolean isTerminated() { + return delegate.isTerminated(); + } + + @Override + public void shutdown() { + delegate.shutdown(); + } + + @Override + public List shutdownNow() { + return delegate.shutdownNow(); + } + + @Override + public Future submit(Callable arg0) { + return delegate.submit(arg0); + } + + @Override + public Future submit(Runnable arg0, T arg1) { + return delegate.submit(arg0, arg1); + } + + @Override + public Future submit(Runnable arg0) { + return delegate.submit(arg0); + } +} diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java new file mode 100644 index 00000000..49464d27 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java @@ -0,0 +1,98 @@ +package tools.refinery.language.web.tests; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; + +public abstract class WebSocketIntegrationTestClient { + private static long TIMEOUT_MILLIS = Duration.ofSeconds(1).toMillis(); + + private boolean finished = false; + + private Object lock = new Object(); + + private Throwable error; + + private int closeStatusCode; + + private List responses = new ArrayList<>(); + + public int getCloseStatusCode() { + return closeStatusCode; + } + + public List getResponses() { + return responses; + } + + @OnWebSocketConnect + public void onConnect(Session session) { + arrangeAndCatchErrors(session); + } + + private void arrangeAndCatchErrors(Session session) { + try { + arrange(session, responses.size()); + } catch (Exception e) { + finishedWithError(e); + } + } + + protected abstract void arrange(Session session, int responsesReceived) throws IOException; + + @OnWebSocketClose + public void onClose(int statusCode, String reason) { + closeStatusCode = statusCode; + testFinished(); + } + + @OnWebSocketError + public void onError(Throwable error) { + finishedWithError(error); + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + responses.add(message); + arrangeAndCatchErrors(session); + } + + private void finishedWithError(Throwable t) { + error = t; + testFinished(); + } + + private void testFinished() { + synchronized (lock) { + finished = true; + lock.notify(); + } + } + + public void waitForTestResult() { + synchronized (lock) { + if (!finished) { + try { + lock.wait(TIMEOUT_MILLIS); + } catch (InterruptedException e) { + fail("Unexpected InterruptedException", e); + } + } + } + if (!finished) { + fail("Test still not finished after timeout"); + } + if (error != null) { + fail("Unexpected exception in websocket thread", error); + } + } +} diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java new file mode 100644 index 00000000..5b8fedba --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java @@ -0,0 +1,165 @@ +package tools.refinery.language.web.xtext.servlet; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.instanceOf; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.testing.InjectWith; +import org.eclipse.xtext.testing.extensions.InjectionExtension; +import org.eclipse.xtext.web.server.model.DocumentStateResult; +import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult; +import org.eclipse.xtext.web.server.validation.ValidationResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.google.inject.Inject; + +import tools.refinery.language.web.tests.AwaitTerminationExecutorServiceProvider; +import tools.refinery.language.web.tests.ProblemWebInjectorProvider; +import tools.refinery.language.web.xtext.server.ResponseHandler; +import tools.refinery.language.web.xtext.server.ResponseHandlerException; +import tools.refinery.language.web.xtext.server.TransactionExecutor; +import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; +import tools.refinery.language.web.xtext.server.message.XtextWebRequest; +import tools.refinery.language.web.xtext.server.message.XtextWebResponse; + +@ExtendWith(MockitoExtension.class) +@ExtendWith(InjectionExtension.class) +@InjectWith(ProblemWebInjectorProvider.class) +class TransactionExecutorTest { + private static final String RESOURCE_NAME = "test.problem"; + + private static final String PROBLEM_CONTENT_TYPE = "application/x-tools.refinery.problem"; + + private static final String TEST_PROBLEM = """ + class Person { + Person[0..*] friend opposite friend + } + + friend(a, b). + """; + + private static final Map UPDATE_FULL_TEXT_PARAMS = Map.of("resource", RESOURCE_NAME, "serviceType", + "update", "fullText", TEST_PROBLEM); + + @Inject + private IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + @Inject + private AwaitTerminationExecutorServiceProvider executorServices; + + private TransactionExecutor transactionExecutor; + + @BeforeEach + void beforeEach() { + transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry); + } + + @Test + void updateFullTextTest() throws ResponseHandlerException { + var captor = newCaptor(); + var stateId = updateFullText(captor); + assertThatPrecomputedMessagesAreReceived(stateId, captor.getAllValues()); + } + + @Test + void updateDeltaTextHighlightAndValidationChange() throws ResponseHandlerException { + var stateId = updateFullText(); + var responseHandler = sendRequestAndWaitForAllResponses( + new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId", + stateId, "deltaText", "individual q.\nnode(q).\n\n", "deltaOffset", "0", "deltaReplaceLength", "0"))); + + var captor = newCaptor(); + verify(responseHandler, times(3)).onResponse(captor.capture()); + var newStateId = getStateId("bar", captor.getAllValues().get(0)); + assertThatPrecomputedMessagesAreReceived(newStateId, captor.getAllValues()); + } + + @Test + void updateDeltaTextHighlightChangeOnly() throws ResponseHandlerException { + var stateId = updateFullText(); + var responseHandler = sendRequestAndWaitForAllResponses( + new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId", + stateId, "deltaText", "indiv q.\nnode(q).\n", "deltaOffset", "0", "deltaReplaceLength", "0"))); + + var captor = newCaptor(); + verify(responseHandler, times(2)).onResponse(captor.capture()); + var newStateId = getStateId("bar", captor.getAllValues().get(0)); + assertHighlightingResponse(newStateId, captor.getAllValues().get(1)); + } + + @Test + void fullTextWithoutResourceTest() throws ResponseHandlerException { + var resourceServiceProvider = resourceServiceProviderRegistry + .getResourceServiceProvider(URI.createFileURI(RESOURCE_NAME)); + resourceServiceProviderRegistry.getContentTypeToFactoryMap().put(PROBLEM_CONTENT_TYPE, resourceServiceProvider); + var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", + Map.of("contentType", PROBLEM_CONTENT_TYPE, "fullText", TEST_PROBLEM, "serviceType", "validate"))); + + var captor = newCaptor(); + verify(responseHandler).onResponse(captor.capture()); + var response = captor.getValue(); + assertThat(response, hasProperty("id", equalTo("foo"))); + assertThat(response, hasProperty("responseData", instanceOf(ValidationResult.class))); + } + + private ArgumentCaptor newCaptor() { + return ArgumentCaptor.forClass(XtextWebResponse.class); + } + + private String updateFullText() throws ResponseHandlerException { + return updateFullText(newCaptor()); + } + + private String updateFullText(ArgumentCaptor captor) throws ResponseHandlerException { + var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", UPDATE_FULL_TEXT_PARAMS)); + + verify(responseHandler, times(3)).onResponse(captor.capture()); + return getStateId("foo", captor.getAllValues().get(0)); + } + + private ResponseHandler sendRequestAndWaitForAllResponses(XtextWebRequest request) throws ResponseHandlerException { + var responseHandler = mock(ResponseHandler.class); + transactionExecutor.setResponseHandler(responseHandler); + transactionExecutor.handleRequest(request); + executorServices.waitForAllTasksToFinish(); + return responseHandler; + } + + private String getStateId(String requestId, XtextWebResponse okResponse) { + assertThat(okResponse, hasProperty("id", equalTo(requestId))); + assertThat(okResponse, hasProperty("responseData", instanceOf(DocumentStateResult.class))); + return ((DocumentStateResult) ((XtextWebOkResponse) okResponse).getResponseData()).getStateId(); + } + + private void assertThatPrecomputedMessagesAreReceived(String stateId, List responses) { + assertHighlightingResponse(stateId, responses.get(1)); + assertValidationResponse(stateId, responses.get(2)); + } + + private void assertHighlightingResponse(String stateId, XtextWebResponse highlightingResponse) { + assertThat(highlightingResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME))); + assertThat(highlightingResponse, hasProperty("stateId", equalTo(stateId))); + assertThat(highlightingResponse, hasProperty("service", equalTo("highlight"))); + assertThat(highlightingResponse, hasProperty("pushData", instanceOf(HighlightingResult.class))); + } + + private void assertValidationResponse(String stateId, XtextWebResponse validationResponse) { + assertThat(validationResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME))); + assertThat(validationResponse, hasProperty("stateId", equalTo(stateId))); + assertThat(validationResponse, hasProperty("service", equalTo("validate"))); + assertThat(validationResponse, hasProperty("pushData", instanceOf(ValidationResult.class))); + } +} diff --git a/subprojects/language-web/tsconfig.json b/subprojects/language-web/tsconfig.json new file mode 100644 index 00000000..cb5f6b13 --- /dev/null +++ b/subprojects/language-web/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/main/js/**/*"], + "exclude": ["./build/generated/sources/lezer/*"] +} diff --git a/subprojects/language-web/tsconfig.sonar.json b/subprojects/language-web/tsconfig.sonar.json new file mode 100644 index 00000000..54eef68b --- /dev/null +++ b/subprojects/language-web/tsconfig.sonar.json @@ -0,0 +1,17 @@ +{ + "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 new file mode 100644 index 00000000..801a705c --- /dev/null +++ b/subprojects/language-web/webpack.config.js @@ -0,0 +1,232 @@ +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', + }, + }, + ], + }), + ], +}; -- cgit v1.2.3-70-g09d2