aboutsummaryrefslogtreecommitdiffstats
path: root/language-web
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-12 17:48:47 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-12 17:48:47 +0100
commitfc7e9312d00e60171ed77c477ed91231d3dbfff9 (patch)
treecc185dd088b5fa6e9357aab3c9062a70626d1953 /language-web
parentbuild: refactor java-application conventions (diff)
downloadrefinery-fc7e9312d00e60171ed77c477ed91231d3dbfff9.tar.gz
refinery-fc7e9312d00e60171ed77c477ed91231d3dbfff9.tar.zst
refinery-fc7e9312d00e60171ed77c477ed91231d3dbfff9.zip
build: move modules into subproject directory
Diffstat (limited to 'language-web')
-rw-r--r--language-web/.editorconfig5
-rw-r--r--language-web/.eslintrc.js40
-rw-r--r--language-web/.stylelintrc.js15
-rw-r--r--language-web/build.gradle147
-rw-r--r--language-web/package.json103
-rw-r--r--language-web/src/main/css/index.scss16
-rw-r--r--language-web/src/main/css/themeVariables.module.scss9
-rw-r--r--language-web/src/main/css/themes.scss38
-rw-r--r--language-web/src/main/html/index.html16
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java52
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java35
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java25
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java29
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java192
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java16
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java44
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java8
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java14
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java26
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java180
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java11
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java79
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java72
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java81
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java57
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java4
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java15
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java23
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java89
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java68
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java33
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java26
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java35
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java9
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java133
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java83
-rw-r--r--language-web/src/main/js/App.tsx60
-rw-r--r--language-web/src/main/js/RootStore.tsx39
-rw-r--r--language-web/src/main/js/editor/EditorArea.tsx152
-rw-r--r--language-web/src/main/js/editor/EditorButtons.tsx98
-rw-r--r--language-web/src/main/js/editor/EditorParent.ts205
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts289
-rw-r--r--language-web/src/main/js/editor/GenerateButton.tsx44
-rw-r--r--language-web/src/main/js/editor/decorationSetExtension.ts39
-rw-r--r--language-web/src/main/js/editor/findOccurrences.ts35
-rw-r--r--language-web/src/main/js/editor/semanticHighlighting.ts24
-rw-r--r--language-web/src/main/js/global.d.ts11
-rw-r--r--language-web/src/main/js/index.tsx69
-rw-r--r--language-web/src/main/js/language/folding.ts115
-rw-r--r--language-web/src/main/js/language/indentation.ts87
-rw-r--r--language-web/src/main/js/language/problem.grammar149
-rw-r--r--language-web/src/main/js/language/problemLanguageSupport.ts92
-rw-r--r--language-web/src/main/js/language/props.ts7
-rw-r--r--language-web/src/main/js/theme/EditorTheme.ts47
-rw-r--r--language-web/src/main/js/theme/ThemeProvider.tsx15
-rw-r--r--language-web/src/main/js/theme/ThemeStore.ts64
-rw-r--r--language-web/src/main/js/utils/ConditionVariable.ts64
-rw-r--r--language-web/src/main/js/utils/PendingTask.ts60
-rw-r--r--language-web/src/main/js/utils/Timer.ts33
-rw-r--r--language-web/src/main/js/utils/logger.ts49
-rw-r--r--language-web/src/main/js/xtext/ContentAssistService.ts219
-rw-r--r--language-web/src/main/js/xtext/HighlightingService.ts37
-rw-r--r--language-web/src/main/js/xtext/OccurrencesService.ts127
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts363
-rw-r--r--language-web/src/main/js/xtext/ValidationService.ts39
-rw-r--r--language-web/src/main/js/xtext/XtextClient.ts86
-rw-r--r--language-web/src/main/js/xtext/XtextWebSocketClient.ts362
-rw-r--r--language-web/src/main/js/xtext/xtextMessages.ts40
-rw-r--r--language-web/src/main/js/xtext/xtextServiceResults.ts112
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java204
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java42
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java47
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java109
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java98
-rw-r--r--language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java165
-rw-r--r--language-web/tsconfig.json18
-rw-r--r--language-web/tsconfig.sonar.json17
-rw-r--r--language-web/webpack.config.js232
78 files changed, 0 insertions, 5992 deletions
diff --git a/language-web/.editorconfig b/language-web/.editorconfig
deleted file mode 100644
index 1b78e967..00000000
--- a/language-web/.editorconfig
+++ /dev/null
@@ -1,5 +0,0 @@
1[src/main/css/xtext/**.css]
2indent_style = tab
3
4[src/main/js/xtext/**.js]
5indent_style = tab
diff --git a/language-web/.eslintrc.js b/language-web/.eslintrc.js
deleted file mode 100644
index b27feb0e..00000000
--- a/language-web/.eslintrc.js
+++ /dev/null
@@ -1,40 +0,0 @@
1// Loosely based on
2// https://github.com/iamturns/create-exposed-app/blob/f14e435b8ce179c89cce3eea89e56202153a53da/.eslintrc.js
3module.exports = {
4 plugins: [
5 '@typescript-eslint',
6 ],
7 extends: [
8 'airbnb',
9 'airbnb-typescript',
10 'airbnb/hooks',
11 'plugin:@typescript-eslint/recommended',
12 'plugin:@typescript-eslint/recommended-requiring-type-checking',
13 ],
14 parserOptions: {
15 project: './tsconfig.json',
16 },
17 rules: {
18 // https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html
19 'import/prefer-default-export': 'off',
20 'import/no-default-export': 'error',
21 // propTypes are for runtime validation, but we rely on TypeScript for build-time validation:
22 // https://github.com/yannickcr/eslint-plugin-react/issues/2275#issuecomment-492003857
23 'react/prop-types': 'off',
24 // Make sure switches are exhaustive: https://stackoverflow.com/a/60166264
25 'default-case': 'off',
26 '@typescript-eslint/switch-exhaustiveness-check': 'error',
27 // https://github.com/airbnb/javascript/pull/2501
28 'react/function-component-definition': ['error', {
29 namedComponents: 'function-expression',
30 namedComponents: 'function-declaration',
31 }],
32 },
33 env: {
34 browser: true,
35 },
36 ignorePatterns: [
37 '*.js',
38 'build/**/*',
39 ],
40};
diff --git a/language-web/.stylelintrc.js b/language-web/.stylelintrc.js
deleted file mode 100644
index 7adf8f26..00000000
--- a/language-web/.stylelintrc.js
+++ /dev/null
@@ -1,15 +0,0 @@
1module.exports = {
2 extends: 'stylelint-config-recommended-scss',
3 // Simplified for only :export to TypeScript based on
4 // https://github.com/pascalduez/stylelint-config-css-modules/blob/d792a6ac7d2bce8239edccbc5a72e0616f22d696/index.js
5 rules: {
6 'selector-pseudo-class-no-unknown': [
7 true,
8 {
9 ignorePseudoClasses: [
10 'export',
11 ],
12 },
13 ],
14 },
15};
diff --git a/language-web/build.gradle b/language-web/build.gradle
deleted file mode 100644
index a549288a..00000000
--- a/language-web/build.gradle
+++ /dev/null
@@ -1,147 +0,0 @@
1plugins {
2 id 'refinery-frontend-workspace'
3 id 'refinery-java-application'
4 id 'refinery-xtext-conventions'
5}
6
7import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn
8
9dependencies {
10 implementation project(':refinery-language')
11 implementation project(':refinery-language-ide')
12 implementation libs.xtend.lib
13 implementation libs.xtext.web
14 implementation libs.jetty.server
15 implementation libs.jetty.servlet
16 implementation libs.jetty.websocket.server
17 implementation libs.slf4j.simple
18 implementation libs.slf4j.log4j
19 testImplementation testFixtures(project(':refinery-language'))
20 testImplementation libs.jetty.websocket.client
21}
22
23def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage')
24
25for (taskName in ['compileJava', 'processResources']) {
26 tasks.named(taskName) {
27 dependsOn generateXtextLanguage
28 }
29}
30
31def webpackOutputDir = "${buildDir}/webpack"
32def productionResources = "${webpackOutputDir}/production"
33def serverMainClass = 'tools.refinery.language.web.ServerLauncher'
34
35frontend {
36 assembleScript = 'assemble:webpack'
37}
38
39def installFrontend = tasks.named('installFrontend')
40
41def generateLezerGrammar = tasks.register('generateLezerGrammar', RunYarn) {
42 dependsOn installFrontend
43 inputs.file('src/main/js/language/problem.grammar')
44 inputs.files('package.json', 'yarn.lock')
45 outputs.file "${buildDir}/generated/sources/lezer/problem.ts"
46 outputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts"
47 script = 'run assemble:lezer'
48}
49
50def assembleFrontend = tasks.named('assembleFrontend')
51assembleFrontend.configure {
52 dependsOn generateLezerGrammar
53 inputs.dir 'src/main/css'
54 inputs.dir 'src/main/html'
55 inputs.dir 'src/main/js'
56 inputs.file "${buildDir}/generated/sources/lezer/problem.ts"
57 inputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts"
58 inputs.files('package.json', 'yarn.lock', 'webpack.config.js')
59 outputs.dir productionResources
60}
61
62def eslint = tasks.register('eslint', RunYarn) {
63 dependsOn installFrontend
64 inputs.dir 'src/main/js'
65 inputs.files('.eslintrc.js', 'tsconfig.json')
66 if (project.hasProperty('ci')) {
67 outputs.file "${buildDir}/eslint.json"
68 script = 'run check:eslint:ci'
69 } else {
70 script = 'run check:eslint'
71 }
72 group = 'verification'
73 description = 'Check for TypeScript errors.'
74}
75
76def stylelint = tasks.register('stylelint', RunYarn) {
77 dependsOn installFrontend
78 inputs.dir 'src/main/css'
79 inputs.file '.stylelintrc.js'
80 if (project.hasProperty('ci')) {
81 outputs.file "${buildDir}/stylelint.json"
82 script = 'run check:stylelint:ci'
83 } else {
84 script = 'run check:stylelint'
85 }
86 group = 'verification'
87 description = 'Check for Sass errors.'
88}
89
90tasks.named('check') {
91 dependsOn(eslint, stylelint)
92}
93
94mainClassName = serverMainClass
95
96tasks.named('jar') {
97 dependsOn assembleFrontend
98 from(productionResources) {
99 into 'webapp'
100 }
101}
102
103tasks.named('shadowJar') {
104 dependsOn assembleFrontend
105 from(project.convention.getPlugin(JavaPluginConvention).sourceSets.main.output)
106 configurations = [project.configurations.runtimeClasspath]
107 exclude('META-INF/INDEX.LIST', 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA','schema/*',
108 '.options', '.api_description', '*.profile', 'about.*', 'about_*.html', 'about_files/*',
109 'plugin.xml', 'systembundle.properties', 'profile.list', 'META-INF/resources/xtext/**')
110 append('plugin.properties')
111 from(productionResources) {
112 into 'webapp'
113 }
114}
115
116def jettyRun = tasks.register('jettyRun', JavaExec) {
117 dependsOn assembleFrontend
118 dependsOn sourceSets.main.runtimeClasspath
119 classpath = sourceSets.main.runtimeClasspath.filter{it.exists()}
120 mainClass = serverMainClass
121 standardInput = System.in
122 environment BASE_RESOURCE: productionResources
123 group = 'run'
124 description = 'Start a Jetty web server serving the Xtex API and assets.'
125}
126
127tasks.register('webpackServe', RunYarn) {
128 dependsOn installFrontend
129 dependsOn generateLezerGrammar
130 outputs.dir "${webpackOutputDir}/development"
131 script = 'run serve'
132 group = 'run'
133 description = 'Start a Webpack dev server with hot module replacement.'
134}
135
136sonarqube.properties {
137 properties['sonar.sources'] += [
138 'src/main/css',
139 'src/main/html',
140 'src/main/js',
141 ]
142 property 'sonar.nodejs.executable', "${frontend.nodeInstallDirectory.get()}/bin/node"
143 property 'sonar.eslint.reportPaths', "${buildDir}/eslint.json"
144 property 'sonar.css.stylelint.reportPaths', "${buildDir}/stylelint.json"
145 // SonarJS does not pick up typescript files with `exactOptionalPropertyTypes`
146 property 'sonar.typescript.tsconfigPath', 'tsconfig.sonar.json'
147}
diff --git a/language-web/package.json b/language-web/package.json
deleted file mode 100644
index 5fa977d9..00000000
--- a/language-web/package.json
+++ /dev/null
@@ -1,103 +0,0 @@
1{
2 "name": "@refinery/language-web",
3 "version": "0.0.0",
4 "description": "Web frontend for VIATRA-Generator",
5 "main": "index.js",
6 "scripts": {
7 "assemble:lezer": "lezer-generator src/main/js/language/problem.grammar -o build/generated/sources/lezer/problem.ts",
8 "assemble:webpack": "webpack --node-env production",
9 "serve": "webpack serve --node-env development --hot",
10 "check": "yarn run check:eslint && yarn run check:stylelint",
11 "check:eslint": "eslint .",
12 "check:eslint:ci": "eslint -f json -o build/eslint.json .",
13 "check:stylelint": "stylelint src/main/css/**/*.scss",
14 "check:stylelint:ci": "stylelint -f json src/main/css/**/*.scss > build/stylelint.json"
15 },
16 "repository": {
17 "type": "git",
18 "url": "git+https://github.com/graphs4value/refinery.git"
19 },
20 "author": "VIATRA-Generator authors",
21 "license": "EPL-2.0",
22 "bugs": {
23 "url": "https://github.com/graphs4value/issues"
24 },
25 "homepage": "https://refinery.tools",
26 "devDependencies": {
27 "@babel/core": "^7.16.0",
28 "@babel/plugin-transform-runtime": "^7.16.4",
29 "@babel/preset-env": "^7.16.4",
30 "@babel/preset-react": "^7.16.0",
31 "@babel/preset-typescript": "^7.16.0",
32 "@lezer/generator": "^0.15.2",
33 "@principalstudio/html-webpack-inject-preload": "^1.2.7",
34 "@types/react": "^17.0.37",
35 "@types/react-dom": "^17.0.11",
36 "@typescript-eslint/eslint-plugin": "^5.6.0",
37 "@typescript-eslint/parser": "^5.6.0",
38 "babel-loader": "^8.2.3",
39 "css-loader": "^6.5.1",
40 "eslint": "^8.4.1",
41 "eslint-config-airbnb": "^19.0.2",
42 "eslint-config-airbnb-typescript": "^16.1.0",
43 "eslint-import-resolver-node": "^0.3.6",
44 "eslint-plugin-import": "^2.25.3",
45 "eslint-plugin-jsx-a11y": "^6.5.1",
46 "eslint-plugin-react": "^7.27.1",
47 "eslint-plugin-react-hooks": "^4.3.0",
48 "html-webpack-plugin": "^5.5.0",
49 "image-webpack-loader": "^8.0.1",
50 "magic-comments-loader": "^1.4.1",
51 "mini-css-extract-plugin": "^2.4.5",
52 "postcss": "^8.4.4",
53 "postcss-scss": "^4.0.2",
54 "sass": "^1.45.0",
55 "sass-loader": "^12.4.0",
56 "style-loader": "^3.3.1",
57 "stylelint": "^14.1.0",
58 "stylelint-config-recommended-scss": "^5.0.2",
59 "stylelint-scss": "^4.0.1",
60 "typescript": "~4.5.3",
61 "webpack": "^5.65.0",
62 "webpack-cli": "^4.9.1",
63 "webpack-dev-server": "^4.6.0",
64 "webpack-subresource-integrity": "^5.0.0"
65 },
66 "dependencies": {
67 "@babel/runtime": "^7.16.3",
68 "@codemirror/autocomplete": "^0.19.9",
69 "@codemirror/closebrackets": "^0.19.0",
70 "@codemirror/commands": "^0.19.6",
71 "@codemirror/comment": "^0.19.0",
72 "@codemirror/fold": "^0.19.2",
73 "@codemirror/gutter": "^0.19.9",
74 "@codemirror/highlight": "^0.19.6",
75 "@codemirror/history": "^0.19.0",
76 "@codemirror/language": "^0.19.7",
77 "@codemirror/lint": "^0.19.3",
78 "@codemirror/matchbrackets": "^0.19.3",
79 "@codemirror/rangeset": "^0.19.2",
80 "@codemirror/rectangular-selection": "^0.19.1",
81 "@codemirror/search": "^0.19.4",
82 "@codemirror/state": "^0.19.6",
83 "@codemirror/view": "^0.19.29",
84 "@emotion/react": "^11.7.0",
85 "@emotion/styled": "^11.6.0",
86 "@fontsource/jetbrains-mono": "^4.5.0",
87 "@fontsource/roboto": "^4.5.1",
88 "@lezer/common": "^0.15.10",
89 "@lezer/lr": "^0.15.5",
90 "@mui/icons-material": "5.2.1",
91 "@mui/material": "5.2.3",
92 "ansi-styles": "^6.1.0",
93 "escape-string-regexp": "^5.0.0",
94 "loglevel": "^1.8.0",
95 "loglevel-plugin-prefix": "^0.8.4",
96 "mobx": "^6.3.8",
97 "mobx-react-lite": "^3.2.2",
98 "nanoid": "^3.1.30",
99 "react": "^17.0.2",
100 "react-dom": "^17.0.2",
101 "zod": "^3.11.6"
102 }
103}
diff --git a/language-web/src/main/css/index.scss b/language-web/src/main/css/index.scss
deleted file mode 100644
index ad876aaf..00000000
--- a/language-web/src/main/css/index.scss
+++ /dev/null
@@ -1,16 +0,0 @@
1@use '@fontsource/roboto/scss/mixins' as Roboto;
2@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono;
3
4$fontWeights: 300, 400, 500, 700;
5@each $weight in $fontWeights {
6 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight);
7 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight, $style: italic);
8}
9
10$monoFontWeights: 400, 700;
11@each $weight in $monoFontWeights {
12 @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight);
13 @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight, $style: italic);
14}
15@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable');
16@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic);
diff --git a/language-web/src/main/css/themeVariables.module.scss b/language-web/src/main/css/themeVariables.module.scss
deleted file mode 100644
index 85af4219..00000000
--- a/language-web/src/main/css/themeVariables.module.scss
+++ /dev/null
@@ -1,9 +0,0 @@
1@import './themes';
2
3:export {
4 @each $themeName, $theme in $themes {
5 @each $variable, $value in $theme {
6 #{$themeName}--#{$variable}: $value,
7 }
8 }
9}
diff --git a/language-web/src/main/css/themes.scss b/language-web/src/main/css/themes.scss
deleted file mode 100644
index a30f1de3..00000000
--- a/language-web/src/main/css/themes.scss
+++ /dev/null
@@ -1,38 +0,0 @@
1$themes: (
2 'dark': (
3 'foreground': #abb2bf,
4 'foregroundHighlight': #eeffff,
5 'background': #212121,
6 'primary': #56b6c2,
7 'secondary': #ff5370,
8 'keyword': #56b6c2,
9 'predicate': #d6e9ff,
10 'variable': #c8ae9d,
11 'uniqueNode': #d6e9ff,
12 'number': #6e88a6,
13 'delimiter': #707787,
14 'comment': #5c6370,
15 'cursor': #56b6c2,
16 'selection': #3e4452,
17 'currentLine': rgba(0, 0, 0, 0.2),
18 'lineNumber': #5c6370,
19 ),
20 'light': (
21 'foreground': #abb2bf,
22 'background': #282c34,
23 'paper': #21252b,
24 'primary': #56b6c2,
25 'secondary': #ff5370,
26 'keyword': #56b6c2,
27 'predicate': #d6e9ff,
28 'variable': #c8ae9d,
29 'uniqueNode': #d6e9ff,
30 'number': #6e88a6,
31 'delimiter': #56606d,
32 'comment': #55606d,
33 'cursor': #f3efe7,
34 'selection': #3e4452,
35 'currentLine': #2c323c,
36 'lineNumber': #5c6370,
37 ),
38);
diff --git a/language-web/src/main/html/index.html b/language-web/src/main/html/index.html
deleted file mode 100644
index f404aa8a..00000000
--- a/language-web/src/main/html/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
1<!DOCTYPE html>
2<html lang="en-US">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>Refinery</title>
7 </head>
8 <body>
9 <noscript>
10 <p>
11 This application requires JavaScript to run.
12 </p>
13 </noscript>
14 <div id="app"></div>
15 </body>
16</html>
diff --git a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
deleted file mode 100644
index b13ae95d..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
+++ /dev/null
@@ -1,52 +0,0 @@
1package tools.refinery.language.web;
2
3import java.io.IOException;
4import java.time.Duration;
5import java.util.regex.Pattern;
6
7import org.eclipse.jetty.http.HttpHeader;
8
9import jakarta.servlet.Filter;
10import jakarta.servlet.FilterChain;
11import jakarta.servlet.FilterConfig;
12import jakarta.servlet.ServletException;
13import jakarta.servlet.ServletRequest;
14import jakarta.servlet.ServletResponse;
15import jakarta.servlet.http.HttpServletRequest;
16import jakarta.servlet.http.HttpServletResponse;
17
18public class CacheControlFilter implements Filter {
19 private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)");
20
21 private static final Duration EXPIRY = Duration.ofDays(365);
22
23 private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable";
24
25 private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate";
26
27 @Override
28 public void init(FilterConfig filterConfig) throws ServletException {
29 // Nothing to initialize.
30 }
31
32 @Override
33 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
34 throws IOException, ServletException {
35 if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) {
36 if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) {
37 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE);
38 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(),
39 System.currentTimeMillis() + EXPIRY.toMillis());
40 } else {
41 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE);
42 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0);
43 }
44 }
45 chain.doFilter(request, response);
46 }
47
48 @Override
49 public void destroy() {
50 // Nothing to dispose.
51 }
52}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
deleted file mode 100644
index ec55036f..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
+++ /dev/null
@@ -1,35 +0,0 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import org.eclipse.xtext.web.server.XtextServiceDispatcher;
7import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
8import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
9import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
10
11import tools.refinery.language.web.occurrences.ProblemOccurrencesService;
12import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher;
13import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess;
14import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider;
15
16/**
17 * Use this class to register additional components to be used within the web application.
18 */
19public class ProblemWebModule extends AbstractProblemWebModule {
20 public Class<? extends IWebDocumentProvider> bindIWebDocumentProvider() {
21 return PushWebDocumentProvider.class;
22 }
23
24 public Class<? extends XtextWebDocumentAccess> bindXtextWebDocumentAccess() {
25 return PushWebDocumentAccess.class;
26 }
27
28 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() {
29 return PushServiceDispatcher.class;
30 }
31
32 public Class<? extends OccurrencesService> bindOccurrencesService() {
33 return ProblemOccurrencesService.class;
34 }
35}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java
deleted file mode 100644
index 4738bc80..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java
+++ /dev/null
@@ -1,25 +0,0 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import org.eclipse.xtext.util.Modules2;
7
8import com.google.inject.Guice;
9import com.google.inject.Injector;
10
11import tools.refinery.language.ProblemRuntimeModule;
12import tools.refinery.language.ProblemStandaloneSetup;
13import tools.refinery.language.ide.ProblemIdeModule;
14
15/**
16 * Initialization support for running Xtext languages in web applications.
17 */
18public class ProblemWebSetup extends ProblemStandaloneSetup {
19
20 @Override
21 public Injector createInjector() {
22 return Guice.createInjector(Modules2.mixin(new ProblemRuntimeModule(), new ProblemIdeModule(), new ProblemWebModule()));
23 }
24
25}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
deleted file mode 100644
index df67b521..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
+++ /dev/null
@@ -1,29 +0,0 @@
1package tools.refinery.language.web;
2
3import org.eclipse.xtext.util.DisposableRegistry;
4
5import jakarta.servlet.ServletException;
6import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
7
8public class ProblemWebSocketServlet extends XtextWebSocketServlet {
9
10 private static final long serialVersionUID = -7040955470384797008L;
11
12 private transient DisposableRegistry disposableRegistry;
13
14 @Override
15 public void init() throws ServletException {
16 super.init();
17 var injector = new ProblemWebSetup().createInjectorAndDoEMFRegistration();
18 this.disposableRegistry = injector.getInstance(DisposableRegistry.class);
19 }
20
21 @Override
22 public void destroy() {
23 if (disposableRegistry != null) {
24 disposableRegistry.dispose();
25 disposableRegistry = null;
26 }
27 super.destroy();
28 }
29}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
deleted file mode 100644
index ffd903d0..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
+++ /dev/null
@@ -1,192 +0,0 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import java.io.File;
7import java.io.IOException;
8import java.net.InetSocketAddress;
9import java.net.URI;
10import java.net.URISyntaxException;
11import java.util.EnumSet;
12import java.util.Optional;
13import java.util.Set;
14
15import org.eclipse.jetty.server.Server;
16import org.eclipse.jetty.server.session.SessionHandler;
17import org.eclipse.jetty.servlet.DefaultServlet;
18import org.eclipse.jetty.servlet.ServletContextHandler;
19import org.eclipse.jetty.servlet.ServletHolder;
20import org.eclipse.jetty.util.resource.Resource;
21import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
22import org.slf4j.Logger;
23import org.slf4j.LoggerFactory;
24
25import jakarta.servlet.DispatcherType;
26import jakarta.servlet.SessionTrackingMode;
27import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
28
29public class ServerLauncher {
30 public static final String DEFAULT_LISTEN_ADDRESS = "localhost";
31
32 public static final int DEFAULT_LISTEN_PORT = 1312;
33
34 public static final int DEFAULT_PUBLIC_PORT = 443;
35
36 public static final int HTTP_DEFAULT_PORT = 80;
37
38 public static final int HTTPS_DEFAULT_PORT = 443;
39
40 public static final String ALLOWED_ORIGINS_SEPARATOR = ";";
41
42 private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class);
43
44 private final Server server;
45
46 public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional<String[]> allowedOrigins) {
47 server = new Server(bindAddress);
48 var handler = new ServletContextHandler();
49 addSessionHandler(handler);
50 addProblemServlet(handler, allowedOrigins);
51 if (baseResource != null) {
52 handler.setBaseResource(baseResource);
53 handler.setWelcomeFiles(new String[] { "index.html" });
54 addDefaultServlet(handler);
55 }
56 handler.addFilter(CacheControlFilter.class, "/*", EnumSet.of(DispatcherType.REQUEST));
57 server.setHandler(handler);
58 }
59
60 private void addSessionHandler(ServletContextHandler handler) {
61 var sessionHandler = new SessionHandler();
62 sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE));
63 handler.setSessionHandler(sessionHandler);
64 }
65
66 private void addProblemServlet(ServletContextHandler handler, Optional<String[]> allowedOrigins) {
67 var problemServletHolder = new ServletHolder(ProblemWebSocketServlet.class);
68 if (allowedOrigins.isEmpty()) {
69 LOG.warn("All WebSocket origins are allowed! This setting should not be used in production!");
70 } else {
71 var allowedOriginsString = String.join(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR,
72 allowedOrigins.get());
73 problemServletHolder.setInitParameter(XtextWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM,
74 allowedOriginsString);
75 }
76 handler.addServlet(problemServletHolder, "/xtext-service");
77 JettyWebSocketServletContainerInitializer.configure(handler, null);
78 }
79
80 private void addDefaultServlet(ServletContextHandler handler) {
81 var defaultServletHolder = new ServletHolder(DefaultServlet.class);
82 var isWindows = System.getProperty("os.name").toLowerCase().contains("win");
83 // Avoid file locking on Windows: https://stackoverflow.com/a/4985717
84 // See also the related Jetty ticket:
85 // https://github.com/eclipse/jetty.project/issues/2925
86 defaultServletHolder.setInitParameter("useFileMappedBuffer", isWindows ? "false" : "true");
87 handler.addServlet(defaultServletHolder, "/");
88 }
89
90 public void start() throws Exception {
91 server.start();
92 LOG.info("Server started on {}", server.getURI());
93 server.join();
94 }
95
96 public static void main(String[] args) {
97 try {
98 var bindAddress = getBindAddress();
99 var baseResource = getBaseResource();
100 var allowedOrigins = getAllowedOrigins();
101 var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins);
102 serverLauncher.start();
103 } catch (Exception exception) {
104 LOG.error("Fatal server error", exception);
105 System.exit(1);
106 }
107 }
108
109 private static String getListenAddress() {
110 var listenAddress = System.getenv("LISTEN_ADDRESS");
111 if (listenAddress == null) {
112 return DEFAULT_LISTEN_ADDRESS;
113 }
114 return listenAddress;
115 }
116
117 private static int getListenPort() {
118 var portStr = System.getenv("LISTEN_PORT");
119 if (portStr != null) {
120 return Integer.parseInt(portStr);
121 }
122 return DEFAULT_LISTEN_PORT;
123 }
124
125 private static InetSocketAddress getBindAddress() {
126 var listenAddress = getListenAddress();
127 var listenPort = getListenPort();
128 return new InetSocketAddress(listenAddress, listenPort);
129 }
130
131 private static Resource getBaseResource() throws IOException, URISyntaxException {
132 var baseResourceOverride = System.getenv("BASE_RESOURCE");
133 if (baseResourceOverride != null) {
134 // If a user override is provided, use it.
135 return Resource.newResource(baseResourceOverride);
136 }
137 var indexUrlInJar = ServerLauncher.class.getResource("/webapp/index.html");
138 if (indexUrlInJar != null) {
139 // If the app is packaged in the jar, serve it.
140 var webRootUri = URI.create(indexUrlInJar.toURI().toASCIIString().replaceFirst("/index.html$", "/"));
141 return Resource.newResource(webRootUri);
142 }
143 // Look for unpacked production artifacts (convenience for running from IDE).
144 var unpackedResourcePathComponents = new String[] { System.getProperty("user.dir"), "build", "webpack",
145 "production" };
146 var unpackedResourceDir = new File(String.join(File.separator, unpackedResourcePathComponents));
147 if (unpackedResourceDir.isDirectory()) {
148 return Resource.newResource(unpackedResourceDir);
149 }
150 // Fall back to just serving a 404.
151 return null;
152 }
153
154 private static String getPublicHost() {
155 var publicHost = System.getenv("PUBLIC_HOST");
156 if (publicHost != null) {
157 return publicHost.toLowerCase();
158 }
159 return null;
160 }
161
162 private static int getPublicPort() {
163 var portStr = System.getenv("PUBLIC_PORT");
164 if (portStr != null) {
165 return Integer.parseInt(portStr);
166 }
167 return DEFAULT_LISTEN_PORT;
168 }
169
170 private static Optional<String[]> getAllowedOrigins() {
171 var allowedOrigins = System.getenv("ALLOWED_ORIGINS");
172 if (allowedOrigins != null) {
173 return Optional.of(allowedOrigins.split(ALLOWED_ORIGINS_SEPARATOR));
174 }
175 return getAllowedOriginsFromPublicHostAndPort();
176 }
177
178 private static Optional<String[]> getAllowedOriginsFromPublicHostAndPort() {
179 var publicHost = getPublicHost();
180 if (publicHost == null) {
181 return Optional.empty();
182 }
183 int publicPort = getPublicPort();
184 var scheme = publicPort == HTTPS_DEFAULT_PORT ? "https" : "http";
185 var urlWithPort = String.format("%s://%s:%d", scheme, publicHost, publicPort);
186 if (publicPort == HTTPS_DEFAULT_PORT || publicPort == HTTP_DEFAULT_PORT) {
187 var urlWithoutPort = String.format("%s://%s", scheme, publicHost);
188 return Optional.of(new String[] { urlWithPort, urlWithoutPort });
189 }
190 return Optional.of(new String[] { urlWithPort });
191 }
192}
diff --git a/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
deleted file mode 100644
index d32bbb54..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
+++ /dev/null
@@ -1,16 +0,0 @@
1package tools.refinery.language.web.occurrences;
2
3import org.eclipse.emf.ecore.EObject;
4import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
5
6import com.google.inject.Singleton;
7
8import tools.refinery.language.model.problem.NamedElement;
9
10@Singleton
11public class ProblemOccurrencesService extends OccurrencesService {
12 @Override
13 protected boolean filter(EObject element) {
14 return super.filter(element) && element instanceof NamedElement;
15 }
16}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
deleted file mode 100644
index fe510f51..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
+++ /dev/null
@@ -1,44 +0,0 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6
7public class PongResult implements IServiceResult {
8 private String pong;
9
10 public PongResult(String pong) {
11 super();
12 this.pong = pong;
13 }
14
15 public String getPong() {
16 return pong;
17 }
18
19 public void setPong(String pong) {
20 this.pong = pong;
21 }
22
23 @Override
24 public int hashCode() {
25 return Objects.hash(pong);
26 }
27
28 @Override
29 public boolean equals(Object obj) {
30 if (this == obj)
31 return true;
32 if (obj == null)
33 return false;
34 if (getClass() != obj.getClass())
35 return false;
36 PongResult other = (PongResult) obj;
37 return Objects.equals(pong, other.pong);
38 }
39
40 @Override
41 public String toString() {
42 return "PongResult [pong=" + pong + "]";
43 }
44}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
deleted file mode 100644
index 2a85afe3..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
+++ /dev/null
@@ -1,8 +0,0 @@
1package tools.refinery.language.web.xtext.server;
2
3import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
4
5@FunctionalInterface
6public interface ResponseHandler {
7 void onResponse(XtextWebResponse response) throws ResponseHandlerException;
8}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
deleted file mode 100644
index 34fcb546..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
+++ /dev/null
@@ -1,14 +0,0 @@
1package tools.refinery.language.web.xtext.server;
2
3public class ResponseHandlerException extends Exception {
4
5 private static final long serialVersionUID = 3589866922420268164L;
6
7 public ResponseHandlerException(String message, Throwable cause) {
8 super(message, cause);
9 }
10
11 public ResponseHandlerException(String message) {
12 super(message);
13 }
14}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
deleted file mode 100644
index 78e00a9e..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
+++ /dev/null
@@ -1,26 +0,0 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Set;
4
5import org.eclipse.xtext.web.server.IServiceContext;
6import org.eclipse.xtext.web.server.ISession;
7
8import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
9
10public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber)
11 implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return delegate.getParameterKeys();
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return delegate.getParameter(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return delegate.getSession();
25 }
26}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
deleted file mode 100644
index 0b417b06..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
+++ /dev/null
@@ -1,180 +0,0 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.lang.ref.WeakReference;
4import java.util.ArrayList;
5import java.util.HashMap;
6import java.util.List;
7import java.util.Map;
8
9import org.eclipse.emf.common.util.URI;
10import org.eclipse.xtext.resource.IResourceServiceProvider;
11import org.eclipse.xtext.util.IDisposable;
12import org.eclipse.xtext.web.server.IServiceContext;
13import org.eclipse.xtext.web.server.IServiceResult;
14import org.eclipse.xtext.web.server.ISession;
15import org.eclipse.xtext.web.server.InvalidRequestException;
16import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException;
17import org.eclipse.xtext.web.server.XtextServiceDispatcher;
18import org.slf4j.Logger;
19import org.slf4j.LoggerFactory;
20
21import com.google.common.base.Strings;
22import com.google.inject.Injector;
23
24import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind;
25import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse;
26import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
27import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage;
28import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
29import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
30import tools.refinery.language.web.xtext.server.push.PushWebDocument;
31import tools.refinery.language.web.xtext.servlet.SimpleServiceContext;
32
33public class TransactionExecutor implements IDisposable, PrecomputationListener {
34 private static final Logger LOG = LoggerFactory.getLogger(TransactionExecutor.class);
35
36 private final ISession session;
37
38 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
39
40 private final Map<String, WeakReference<PushWebDocument>> subscriptions = new HashMap<>();
41
42 private ResponseHandler responseHandler;
43
44 private Object callPendingLock = new Object();
45
46 private boolean callPending;
47
48 private List<XtextWebPushMessage> pendingPushMessages = new ArrayList<>();
49
50 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
51 this.session = session;
52 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
53 }
54
55 public void setResponseHandler(ResponseHandler responseHandler) {
56 this.responseHandler = responseHandler;
57 }
58
59 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException {
60 var serviceContext = new SimpleServiceContext(session, request.getRequestData());
61 var ping = serviceContext.getParameter("ping");
62 if (ping != null) {
63 responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping)));
64 return;
65 }
66 synchronized (callPendingLock) {
67 if (callPending) {
68 LOG.error("Reentrant request detected");
69 }
70 if (!pendingPushMessages.isEmpty()) {
71 LOG.error("{} push messages got stuck without a pending request", pendingPushMessages.size());
72 }
73 callPending = true;
74 }
75 try {
76 var injector = getInjector(serviceContext);
77 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
78 var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this));
79 var serviceResult = service.getService().apply();
80 responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult));
81 } catch (InvalidRequestException e) {
82 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e));
83 } catch (RuntimeException e) {
84 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e));
85 } finally {
86 synchronized (callPendingLock) {
87 for (var message : pendingPushMessages) {
88 try {
89 responseHandler.onResponse(message);
90 } catch (ResponseHandlerException | RuntimeException e) {
91 LOG.error("Error while flushing push message", e);
92 }
93 }
94 pendingPushMessages.clear();
95 callPending = false;
96 }
97 }
98 }
99
100 @Override
101 public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName,
102 IServiceResult serviceResult) throws ResponseHandlerException {
103 var message = new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult);
104 synchronized (callPendingLock) {
105 // If we're currently responding to a call we must delay any push messages until
106 // the reply is sent, because push messages relating to the new state id must be
107 // sent after the response with the new state id so that the client knows about
108 // the new state when it receives the push message.
109 if (callPending) {
110 pendingPushMessages.add(message);
111 } else {
112 responseHandler.onResponse(message);
113 }
114 }
115 }
116
117 @Override
118 public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
119 PushWebDocument previousDocument = null;
120 var previousSubscription = subscriptions.get(resourceId);
121 if (previousSubscription != null) {
122 previousDocument = previousSubscription.get();
123 }
124 if (previousDocument == document) {
125 return;
126 }
127 if (previousDocument != null) {
128 previousDocument.removePrecomputationListener(this);
129 }
130 subscriptions.put(resourceId, new WeakReference<>(document));
131 }
132
133 /**
134 * Get the injector to satisfy the request in the {@code serviceContext}.
135 *
136 * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}.
137 *
138 * @param serviceContext the Xtext service context of the request
139 * @return the injector for the Xtext language in the request
140 * @throws UnknownLanguageException if the Xtext language cannot be determined
141 */
142 protected Injector getInjector(IServiceContext context) {
143 IResourceServiceProvider resourceServiceProvider = null;
144 var resourceName = context.getParameter("resource");
145 if (resourceName == null) {
146 resourceName = "";
147 }
148 var emfURI = URI.createURI(resourceName);
149 var contentType = context.getParameter("contentType");
150 if (Strings.isNullOrEmpty(contentType)) {
151 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI);
152 if (resourceServiceProvider == null) {
153 if (emfURI.toString().isEmpty()) {
154 throw new UnknownLanguageException(
155 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
156 } else {
157 throw new UnknownLanguageException(
158 "Unable to identify the Xtext language for resource " + emfURI + ".");
159 }
160 }
161 } else {
162 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType);
163 if (resourceServiceProvider == null) {
164 throw new UnknownLanguageException(
165 "Unable to identify the Xtext language for contentType " + contentType + ".");
166 }
167 }
168 return resourceServiceProvider.get(Injector.class);
169 }
170
171 @Override
172 public void dispose() {
173 for (var subscription : subscriptions.values()) {
174 var document = subscription.get();
175 if (document != null) {
176 document.removePrecomputationListener(this);
177 }
178 }
179 }
180}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
deleted file mode 100644
index f74bae74..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
+++ /dev/null
@@ -1,11 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import com.google.gson.annotations.SerializedName;
4
5public enum XtextWebErrorKind {
6 @SerializedName("request")
7 REQUEST_ERROR,
8
9 @SerializedName("server")
10 SERVER_ERROR,
11}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
deleted file mode 100644
index 01d78c31..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
+++ /dev/null
@@ -1,79 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebErrorResponse implements XtextWebResponse {
8 private String id;
9
10 @SerializedName("error")
11 private XtextWebErrorKind errorKind;
12
13 @SerializedName("message")
14 private String errorMessage;
15
16 public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) {
17 super();
18 this.id = id;
19 this.errorKind = errorKind;
20 this.errorMessage = errorMessage;
21 }
22
23 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind,
24 String errorMessage) {
25 this(request.getId(), errorKind, errorMessage);
26 }
27
28 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) {
29 this(request, errorKind, t.getMessage());
30 }
31
32 public String getId() {
33 return id;
34 }
35
36 public void setId(String id) {
37 this.id = id;
38 }
39
40 public XtextWebErrorKind getErrorKind() {
41 return errorKind;
42 }
43
44 public void setErrorKind(XtextWebErrorKind errorKind) {
45 this.errorKind = errorKind;
46 }
47
48 public String getErrorMessage() {
49 return errorMessage;
50 }
51
52 public void setErrorMessage(String errorMessage) {
53 this.errorMessage = errorMessage;
54 }
55
56 @Override
57 public int hashCode() {
58 return Objects.hash(errorKind, errorMessage, id);
59 }
60
61 @Override
62 public boolean equals(Object obj) {
63 if (this == obj)
64 return true;
65 if (obj == null)
66 return false;
67 if (getClass() != obj.getClass())
68 return false;
69 XtextWebErrorResponse other = (XtextWebErrorResponse) obj;
70 return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage)
71 && Objects.equals(id, other.id);
72 }
73
74 @Override
75 public String toString() {
76 return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage
77 + "]";
78 }
79}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
deleted file mode 100644
index 8af27247..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
+++ /dev/null
@@ -1,72 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6import org.eclipse.xtext.web.server.IUnwrappableServiceResult;
7
8import com.google.gson.annotations.SerializedName;
9
10public final class XtextWebOkResponse implements XtextWebResponse {
11 private String id;
12
13 @SerializedName("response")
14 private Object responseData;
15
16 public XtextWebOkResponse(String id, Object responseData) {
17 super();
18 this.id = id;
19 this.responseData = responseData;
20 }
21
22 public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) {
23 this(request.getId(), maybeUnwrap(result));
24 }
25
26 public String getId() {
27 return id;
28 }
29
30 public void setId(String id) {
31 this.id = id;
32 }
33
34 public Object getResponseData() {
35 return responseData;
36 }
37
38 public void setResponseData(Object responseData) {
39 this.responseData = responseData;
40 }
41
42 @Override
43 public int hashCode() {
44 return Objects.hash(id, responseData);
45 }
46
47 @Override
48 public boolean equals(Object obj) {
49 if (this == obj)
50 return true;
51 if (obj == null)
52 return false;
53 if (getClass() != obj.getClass())
54 return false;
55 XtextWebOkResponse other = (XtextWebOkResponse) obj;
56 return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData);
57 }
58
59 @Override
60 public String toString() {
61 return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]";
62 }
63
64 private static Object maybeUnwrap(IServiceResult result) {
65 if (result instanceof IUnwrappableServiceResult unwrappableServiceResult
66 && unwrappableServiceResult.getContent() != null) {
67 return unwrappableServiceResult.getContent();
68 } else {
69 return result;
70 }
71 }
72}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
deleted file mode 100644
index c9432e1c..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
+++ /dev/null
@@ -1,81 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebPushMessage implements XtextWebResponse {
8 @SerializedName("resource")
9 private String resourceId;
10
11 private String stateId;
12
13 private String service;
14
15 @SerializedName("push")
16 private Object pushData;
17
18 public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) {
19 super();
20 this.resourceId = resourceId;
21 this.stateId = stateId;
22 this.service = service;
23 this.pushData = pushData;
24 }
25
26 public String getResourceId() {
27 return resourceId;
28 }
29
30 public void setResourceId(String resourceId) {
31 this.resourceId = resourceId;
32 }
33
34 public String getStateId() {
35 return stateId;
36 }
37
38 public void setStateId(String stateId) {
39 this.stateId = stateId;
40 }
41
42 public String getService() {
43 return service;
44 }
45
46 public void setService(String service) {
47 this.service = service;
48 }
49
50 public Object getPushData() {
51 return pushData;
52 }
53
54 public void setPushData(Object pushData) {
55 this.pushData = pushData;
56 }
57
58 @Override
59 public int hashCode() {
60 return Objects.hash(pushData, resourceId, service, stateId);
61 }
62
63 @Override
64 public boolean equals(Object obj) {
65 if (this == obj)
66 return true;
67 if (obj == null)
68 return false;
69 if (getClass() != obj.getClass())
70 return false;
71 XtextWebPushMessage other = (XtextWebPushMessage) obj;
72 return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId)
73 && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId);
74 }
75
76 @Override
77 public String toString() {
78 return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service
79 + ", pushData=" + pushData + "]";
80 }
81}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
deleted file mode 100644
index 959749f8..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
+++ /dev/null
@@ -1,57 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Map;
4import java.util.Objects;
5
6import com.google.gson.annotations.SerializedName;
7
8public class XtextWebRequest {
9 private String id;
10
11 @SerializedName("request")
12 private Map<String, String> requestData;
13
14 public XtextWebRequest(String id, Map<String, String> requestData) {
15 super();
16 this.id = id;
17 this.requestData = requestData;
18 }
19
20 public String getId() {
21 return id;
22 }
23
24 public void setId(String id) {
25 this.id = id;
26 }
27
28 public Map<String, String> getRequestData() {
29 return requestData;
30 }
31
32 public void setRequestData(Map<String, String> requestData) {
33 this.requestData = requestData;
34 }
35
36 @Override
37 public int hashCode() {
38 return Objects.hash(id, requestData);
39 }
40
41 @Override
42 public boolean equals(Object obj) {
43 if (this == obj)
44 return true;
45 if (obj == null)
46 return false;
47 if (getClass() != obj.getClass())
48 return false;
49 XtextWebRequest other = (XtextWebRequest) obj;
50 return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData);
51 }
52
53 @Override
54 public String toString() {
55 return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]";
56 }
57}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
deleted file mode 100644
index 3bd13047..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
+++ /dev/null
@@ -1,4 +0,0 @@
1package tools.refinery.language.web.xtext.server.message;
2
3public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage {
4}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
deleted file mode 100644
index 79a284db..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
+++ /dev/null
@@ -1,15 +0,0 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceResult;
4
5import tools.refinery.language.web.xtext.server.ResponseHandlerException;
6
7@FunctionalInterface
8public interface PrecomputationListener {
9 void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult)
10 throws ResponseHandlerException;
11
12 default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
13 // Nothing to handle by default.
14 }
15}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
deleted file mode 100644
index c7b8108d..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
+++ /dev/null
@@ -1,23 +0,0 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.XtextServiceDispatcher;
5import org.eclipse.xtext.web.server.model.XtextWebDocument;
6
7import com.google.inject.Singleton;
8
9import tools.refinery.language.web.xtext.server.SubscribingServiceContext;
10
11@Singleton
12public class PushServiceDispatcher extends XtextServiceDispatcher {
13
14 @Override
15 protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) {
16 var document = super.getFullTextDocument(fullText, resourceId, context);
17 if (document instanceof PushWebDocument pushWebDocument
18 && context instanceof SubscribingServiceContext subscribingContext) {
19 pushWebDocument.addPrecomputationListener(subscribingContext.subscriber());
20 }
21 return document;
22 }
23}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
deleted file mode 100644
index 906b9e30..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
+++ /dev/null
@@ -1,89 +0,0 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import java.util.ArrayList;
4import java.util.HashMap;
5import java.util.List;
6import java.util.Map;
7
8import org.eclipse.xtext.util.CancelIndicator;
9import org.eclipse.xtext.web.server.IServiceResult;
10import org.eclipse.xtext.web.server.model.AbstractCachedService;
11import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
12import org.eclipse.xtext.web.server.model.XtextWebDocument;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import com.google.common.collect.ImmutableList;
17
18import tools.refinery.language.web.xtext.server.ResponseHandlerException;
19
20public class PushWebDocument extends XtextWebDocument {
21 private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class);
22
23 private final List<PrecomputationListener> precomputationListeners = new ArrayList<>();
24
25 private final Map<Class<?>, IServiceResult> precomputedServices = new HashMap<>();
26
27 public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) {
28 super(resourceId, synchronizer);
29 if (resourceId == null) {
30 throw new IllegalArgumentException("resourceId must not be null");
31 }
32 }
33
34 public boolean addPrecomputationListener(PrecomputationListener listener) {
35 synchronized (precomputationListeners) {
36 if (precomputationListeners.contains(listener)) {
37 return false;
38 }
39 precomputationListeners.add(listener);
40 listener.onSubscribeToPrecomputationEvents(getResourceId(), this);
41 return true;
42 }
43 }
44
45 public boolean removePrecomputationListener(PrecomputationListener listener) {
46 synchronized (precomputationListeners) {
47 return precomputationListeners.remove(listener);
48 }
49 }
50
51 public <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, String serviceName,
52 CancelIndicator cancelIndicator, boolean logCacheMiss) {
53 var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss);
54 if (result == null) {
55 LOG.error("{} service returned null result", serviceName);
56 return;
57 }
58 var serviceClass = service.getClass();
59 var previousResult = precomputedServices.get(serviceClass);
60 if (previousResult != null && previousResult.equals(result)) {
61 return;
62 }
63 precomputedServices.put(serviceClass, result);
64 notifyPrecomputationListeners(serviceName, result);
65 }
66
67 private <T extends IServiceResult> void notifyPrecomputationListeners(String serviceName, T result) {
68 var resourceId = getResourceId();
69 var stateId = getStateId();
70 List<PrecomputationListener> copyOfListeners;
71 synchronized (precomputationListeners) {
72 copyOfListeners = ImmutableList.copyOf(precomputationListeners);
73 }
74 var toRemove = new ArrayList<PrecomputationListener>();
75 for (var listener : copyOfListeners) {
76 try {
77 listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result);
78 } catch (ResponseHandlerException e) {
79 LOG.error("Delivering precomputation push message failed", e);
80 toRemove.add(listener);
81 }
82 }
83 if (!toRemove.isEmpty()) {
84 synchronized (precomputationListeners) {
85 precomputationListeners.removeAll(toRemove);
86 }
87 }
88 }
89}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
deleted file mode 100644
index b3666a86..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
+++ /dev/null
@@ -1,68 +0,0 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.service.OperationCanceledManager;
4import org.eclipse.xtext.util.CancelIndicator;
5import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork;
6import org.eclipse.xtext.web.server.IServiceResult;
7import org.eclipse.xtext.web.server.model.AbstractCachedService;
8import org.eclipse.xtext.web.server.model.IXtextWebDocument;
9import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry;
10import org.eclipse.xtext.web.server.model.XtextWebDocument;
11import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
12import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService;
13import org.eclipse.xtext.web.server.validation.ValidationService;
14
15import com.google.inject.Inject;
16
17public class PushWebDocumentAccess extends XtextWebDocumentAccess {
18
19 @Inject
20 private PrecomputedServiceRegistry preComputedServiceRegistry;
21
22 @Inject
23 private OperationCanceledManager operationCanceledManager;
24
25 private PushWebDocument pushDocument;
26
27 @Override
28 protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) {
29 super.init(document, requiredStateId, skipAsyncWork);
30 if (document instanceof PushWebDocument newPushDocument) {
31 pushDocument = newPushDocument;
32 }
33 }
34
35 @Override
36 protected void performPrecomputation(CancelIndicator cancelIndicator) {
37 if (pushDocument == null) {
38 super.performPrecomputation(cancelIndicator);
39 return;
40 }
41 for (AbstractCachedService<? extends IServiceResult> service : preComputedServiceRegistry
42 .getPrecomputedServices()) {
43 operationCanceledManager.checkCanceled(cancelIndicator);
44 precomputeServiceResult(service, false);
45 }
46 }
47
48 protected <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, boolean logCacheMiss) {
49 var serviceName = getPrecomputedServiceName(service);
50 readOnly(new CancelableUnitOfWork<Void, IXtextWebDocument>() {
51 @Override
52 public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception {
53 pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss);
54 return null;
55 }
56 });
57 }
58
59 protected String getPrecomputedServiceName(AbstractCachedService<? extends IServiceResult> service) {
60 if (service instanceof ValidationService) {
61 return "validate";
62 }
63 if (service instanceof HighlightingService) {
64 return "highlight";
65 }
66 throw new IllegalArgumentException("Unknown precomputed service: " + service);
67 }
68}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
deleted file mode 100644
index fc45f74a..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
+++ /dev/null
@@ -1,33 +0,0 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
5import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
6import org.eclipse.xtext.web.server.model.XtextWebDocument;
7
8import com.google.inject.Inject;
9import com.google.inject.Provider;
10import com.google.inject.Singleton;
11
12/**
13 * Based on
14 * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}.
15 *
16 * @author Kristóf Marussy
17 */
18@Singleton
19public class PushWebDocumentProvider implements IWebDocumentProvider {
20 @Inject
21 private Provider<DocumentSynchronizer> synchronizerProvider;
22
23 @Override
24 public XtextWebDocument get(String resourceId, IServiceContext serviceContext) {
25 if (resourceId == null) {
26 return new XtextWebDocument(resourceId, synchronizerProvider.get());
27 } else {
28 // We only need to send push messages if a resourceId is specified.
29 return new PushWebDocument(resourceId,
30 serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get()));
31 }
32 }
33}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
deleted file mode 100644
index 43e37160..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
+++ /dev/null
@@ -1,26 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.Map;
4import java.util.Set;
5
6import org.eclipse.xtext.web.server.IServiceContext;
7import org.eclipse.xtext.web.server.ISession;
8
9import com.google.common.collect.ImmutableSet;
10
11public record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return ImmutableSet.copyOf(parameters.keySet());
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return parameters.get(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return session;
25 }
26}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
deleted file mode 100644
index 09c055a2..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
+++ /dev/null
@@ -1,35 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.HashMap;
4import java.util.Map;
5
6import org.eclipse.xtext.web.server.ISession;
7import org.eclipse.xtext.xbase.lib.Functions.Function0;
8
9public class SimpleSession implements ISession {
10 private Map<Object, Object> map = new HashMap<>();
11
12 @Override
13 public <T> T get(Object key) {
14 @SuppressWarnings("unchecked")
15 var value = (T) map.get(key);
16 return value;
17 }
18
19 @Override
20 public <T> T get(Object key, Function0<? extends T> factory) {
21 @SuppressWarnings("unchecked")
22 var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply());
23 return value;
24 }
25
26 @Override
27 public void put(Object key, Object value) {
28 map.put(key, value);
29 }
30
31 @Override
32 public void remove(Object key) {
33 map.remove(key);
34 }
35}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
deleted file mode 100644
index 0cd229e8..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
+++ /dev/null
@@ -1,9 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3public final class XtextStatusCode {
4 public static final int INVALID_JSON = 4007;
5
6 private XtextStatusCode() {
7 throw new IllegalStateException("This is a static utility class and should not be instantiated directly");
8 }
9}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
deleted file mode 100644
index fd41f213..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
+++ /dev/null
@@ -1,133 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.io.Reader;
5
6import org.eclipse.jetty.websocket.api.Session;
7import org.eclipse.jetty.websocket.api.StatusCode;
8import org.eclipse.jetty.websocket.api.WriteCallback;
9import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
10import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
13import org.eclipse.jetty.websocket.api.annotations.WebSocket;
14import org.eclipse.xtext.resource.IResourceServiceProvider;
15import org.eclipse.xtext.web.server.ISession;
16import org.slf4j.Logger;
17import org.slf4j.LoggerFactory;
18
19import com.google.gson.Gson;
20import com.google.gson.JsonIOException;
21import com.google.gson.JsonParseException;
22
23import tools.refinery.language.web.xtext.server.ResponseHandler;
24import tools.refinery.language.web.xtext.server.ResponseHandlerException;
25import tools.refinery.language.web.xtext.server.TransactionExecutor;
26import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
27import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
28
29@WebSocket
30public class XtextWebSocket implements WriteCallback, ResponseHandler {
31 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
32
33 private final Gson gson = new Gson();
34
35 private final TransactionExecutor executor;
36
37 private Session webSocketSession;
38
39 public XtextWebSocket(TransactionExecutor executor) {
40 this.executor = executor;
41 executor.setResponseHandler(this);
42 }
43
44 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
45 this(new TransactionExecutor(session, resourceServiceProviderRegistry));
46 }
47
48 @OnWebSocketConnect
49 public void onConnect(Session webSocketSession) {
50 if (this.webSocketSession != null) {
51 LOG.error("Websocket session onConnect when already connected");
52 return;
53 }
54 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
55 this.webSocketSession = webSocketSession;
56 }
57
58 @OnWebSocketClose
59 public void onClose(int statusCode, String reason) {
60 executor.dispose();
61 if (webSocketSession == null) {
62 return;
63 }
64 if (statusCode == StatusCode.NORMAL || statusCode == StatusCode.SHUTDOWN) {
65 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
66 } else {
67 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
68 reason);
69 }
70 webSocketSession = null;
71 }
72
73 @OnWebSocketError
74 public void onError(Throwable error) {
75 if (webSocketSession == null) {
76 return;
77 }
78 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
79 }
80
81 @OnWebSocketMessage
82 public void onMessage(Reader reader) {
83 if (webSocketSession == null) {
84 LOG.error("Trying to receive message when websocket is disconnected");
85 return;
86 }
87 XtextWebRequest request;
88 try {
89 request = gson.fromJson(reader, XtextWebRequest.class);
90 } catch (JsonIOException e) {
91 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
92 if (webSocketSession.isOpen()) {
93 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload");
94 }
95 return;
96 } catch (JsonParseException e) {
97 LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e);
98 webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload");
99 return;
100 }
101 try {
102 executor.handleRequest(request);
103 } catch (ResponseHandlerException e) {
104 LOG.warn("Cannot write websocket response", e);
105 if (webSocketSession.isOpen()) {
106 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response");
107 }
108 }
109 }
110
111 @Override
112 public void onResponse(XtextWebResponse response) throws ResponseHandlerException {
113 if (webSocketSession == null) {
114 throw new ResponseHandlerException("Trying to send message when websocket is disconnected");
115 }
116 var responseString = gson.toJson(response);
117 try {
118 webSocketSession.getRemote().sendPartialString(responseString, true, this);
119 } catch (IOException e) {
120 throw new ResponseHandlerException(
121 "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
122 }
123 }
124
125 @Override
126 public void writeFailed(Throwable x) {
127 if (webSocketSession == null) {
128 LOG.error("Cannot complete async write to disconnected websocket", x);
129 return;
130 }
131 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
132 }
133}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
deleted file mode 100644
index 942ca380..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
+++ /dev/null
@@ -1,83 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.time.Duration;
5import java.util.Set;
6
7import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
8import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
9import org.eclipse.jetty.websocket.server.JettyWebSocketCreator;
10import org.eclipse.jetty.websocket.server.JettyWebSocketServlet;
11import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;
12import org.eclipse.xtext.resource.IResourceServiceProvider;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import jakarta.servlet.ServletConfig;
17import jakarta.servlet.ServletException;
18
19public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator {
20
21 private static final long serialVersionUID = -3772740838165122685L;
22
23 public static final String ALLOWED_ORIGINS_SEPARATOR = ";";
24
25 public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin";
26
27 public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1";
28
29 /**
30 * Maximum message size should be large enough to upload a full model file.
31 */
32 private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L;
33
34 private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30);
35
36 private transient Logger log = LoggerFactory.getLogger(getClass());
37
38 private transient Set<String> allowedOrigins = null;
39
40 @Override
41 public void init(ServletConfig config) throws ServletException {
42 var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM);
43 if (allowedOriginsStr == null) {
44 log.warn("All WebSocket origins are allowed! This setting should not be used in production!");
45 } else {
46 allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR));
47 log.info("Allowed origins: {}", allowedOrigins);
48 }
49 super.init(config);
50 }
51
52 @Override
53 protected void configure(JettyWebSocketServletFactory factory) {
54 factory.setMaxFrameSize(MAX_FRAME_SIZE);
55 factory.setIdleTimeout(IDLE_TIMEOUT);
56 factory.addMapping("/", this);
57 }
58
59 @Override
60 public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) {
61 if (allowedOrigins != null) {
62 var origin = req.getOrigin();
63 if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) {
64 log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin);
65 try {
66 resp.sendForbidden("Origin not allowed");
67 } catch (IOException e) {
68 log.error("Cannot send forbidden origin error", e);
69 }
70 return null;
71 }
72 }
73 if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) {
74 resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1);
75 } else {
76 log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(),
77 req.getRemoteSocketAddress());
78 resp.setAcceptedSubProtocol(null);
79 }
80 var session = new SimpleSession();
81 return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE);
82 }
83}
diff --git a/language-web/src/main/js/App.tsx b/language-web/src/main/js/App.tsx
deleted file mode 100644
index 54f92f9a..00000000
--- a/language-web/src/main/js/App.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
1import AppBar from '@mui/material/AppBar';
2import Box from '@mui/material/Box';
3import IconButton from '@mui/material/IconButton';
4import Toolbar from '@mui/material/Toolbar';
5import Typography from '@mui/material/Typography';
6import MenuIcon from '@mui/icons-material/Menu';
7import React from 'react';
8
9import { EditorArea } from './editor/EditorArea';
10import { EditorButtons } from './editor/EditorButtons';
11import { GenerateButton } from './editor/GenerateButton';
12
13export function App(): JSX.Element {
14 return (
15 <Box
16 display="flex"
17 flexDirection="column"
18 sx={{ height: '100vh' }}
19 >
20 <AppBar
21 position="static"
22 color="inherit"
23 >
24 <Toolbar>
25 <IconButton
26 edge="start"
27 sx={{ mr: 2 }}
28 color="inherit"
29 aria-label="menu"
30 >
31 <MenuIcon />
32 </IconButton>
33 <Typography
34 variant="h6"
35 component="h1"
36 flexGrow={1}
37 >
38 Refinery
39 </Typography>
40 </Toolbar>
41 </AppBar>
42 <Box
43 display="flex"
44 justifyContent="space-between"
45 alignItems="center"
46 p={1}
47 >
48 <EditorButtons />
49 <GenerateButton />
50 </Box>
51 <Box
52 flexGrow={1}
53 flexShrink={1}
54 sx={{ overflow: 'auto' }}
55 >
56 <EditorArea />
57 </Box>
58 </Box>
59 );
60}
diff --git a/language-web/src/main/js/RootStore.tsx b/language-web/src/main/js/RootStore.tsx
deleted file mode 100644
index baf0b61e..00000000
--- a/language-web/src/main/js/RootStore.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
1import React, { createContext, useContext } from 'react';
2
3import { EditorStore } from './editor/EditorStore';
4import { ThemeStore } from './theme/ThemeStore';
5
6export class RootStore {
7 editorStore;
8
9 themeStore;
10
11 constructor(initialValue: string) {
12 this.themeStore = new ThemeStore();
13 this.editorStore = new EditorStore(initialValue, this.themeStore);
14 }
15}
16
17const StoreContext = createContext<RootStore | undefined>(undefined);
18
19export interface RootStoreProviderProps {
20 children: JSX.Element;
21
22 rootStore: RootStore;
23}
24
25export function RootStoreProvider({ children, rootStore }: RootStoreProviderProps): JSX.Element {
26 return (
27 <StoreContext.Provider value={rootStore}>
28 {children}
29 </StoreContext.Provider>
30 );
31}
32
33export const useRootStore = (): RootStore => {
34 const rootStore = useContext(StoreContext);
35 if (!rootStore) {
36 throw new Error('useRootStore must be used within RootStoreProvider');
37 }
38 return rootStore;
39};
diff --git a/language-web/src/main/js/editor/EditorArea.tsx b/language-web/src/main/js/editor/EditorArea.tsx
deleted file mode 100644
index dba20f6e..00000000
--- a/language-web/src/main/js/editor/EditorArea.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint';
4import { observer } from 'mobx-react-lite';
5import React, {
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
11
12import { EditorParent } from './EditorParent';
13import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger';
15
16const log = getLogger('editor.EditorArea');
17
18function usePanel(
19 panelId: string,
20 stateToSet: boolean,
21 editorView: EditorView | null,
22 openCommand: Command,
23 closeCommand: Command,
24 closeCallback: () => void,
25) {
26 const [cachedViewState, setCachedViewState] = useState<boolean>(false);
27 useEffect(() => {
28 if (editorView === null || cachedViewState === stateToSet) {
29 return;
30 }
31 if (stateToSet) {
32 openCommand(editorView);
33 const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
34 const closeButton = editorView.dom.querySelector(buttonQuery);
35 if (closeButton) {
36 log.debug('Addig close button callback to', panelId, 'panel');
37 // We must remove the event listener added by CodeMirror from the button
38 // that dispatches a transaction without going through `EditorStorre`.
39 // Cloning a DOM node removes event listeners,
40 // see https://stackoverflow.com/a/9251864
41 const closeButtonWithoutListeners = closeButton.cloneNode(true);
42 closeButtonWithoutListeners.addEventListener('click', (event) => {
43 closeCallback();
44 event.preventDefault();
45 });
46 closeButton.replaceWith(closeButtonWithoutListeners);
47 } else {
48 log.error('Opened', panelId, 'panel has no close button');
49 }
50 } else {
51 closeCommand(editorView);
52 }
53 setCachedViewState(stateToSet);
54 }, [
55 stateToSet,
56 editorView,
57 cachedViewState,
58 panelId,
59 openCommand,
60 closeCommand,
61 closeCallback,
62 ]);
63 return setCachedViewState;
64}
65
66function fixCodeMirrorAccessibility(editorView: EditorView) {
67 // Reported by Lighthouse 8.3.0.
68 const { contentDOM } = editorView;
69 contentDOM.removeAttribute('aria-expanded');
70 contentDOM.setAttribute('aria-label', 'Code editor');
71}
72
73export const EditorArea = observer(() => {
74 const { editorStore } = useRootStore();
75 const editorParentRef = useRef<HTMLDivElement | null>(null);
76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null);
77
78 const setSearchPanelOpen = usePanel(
79 'search',
80 editorStore.showSearchPanel,
81 editorViewState,
82 openSearchPanel,
83 closeSearchPanel,
84 useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
85 );
86
87 const setLintPanelOpen = usePanel(
88 'panel-lint',
89 editorStore.showLintPanel,
90 editorViewState,
91 openLintPanel,
92 closeLintPanel,
93 useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
94 );
95
96 useEffect(() => {
97 if (editorParentRef.current === null) {
98 return () => {
99 // Nothing to clean up.
100 };
101 }
102
103 const editorView = new EditorView({
104 state: editorStore.state,
105 parent: editorParentRef.current,
106 dispatch: (transaction) => {
107 editorStore.onTransaction(transaction);
108 editorView.update([transaction]);
109 if (editorView.state !== editorStore.state) {
110 log.error(
111 'Failed to synchronize editor state - store state:',
112 editorStore.state,
113 'view state:',
114 editorView.state,
115 );
116 }
117 },
118 });
119 fixCodeMirrorAccessibility(editorView);
120 setEditorViewState(editorView);
121 setSearchPanelOpen(false);
122 setLintPanelOpen(false);
123 // `dispatch` is bound to the view instance,
124 // so it does not have to be called as a method.
125 // eslint-disable-next-line @typescript-eslint/unbound-method
126 editorStore.updateDispatcher(editorView.dispatch);
127 log.info('Editor created');
128
129 return () => {
130 editorStore.updateDispatcher(null);
131 editorView.destroy();
132 log.info('Editor destroyed');
133 };
134 }, [
135 editorParentRef,
136 editorStore,
137 setSearchPanelOpen,
138 setLintPanelOpen,
139 ]);
140
141 return (
142 <EditorParent
143 className="dark"
144 sx={{
145 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : {
146 display: 'none !important',
147 },
148 }}
149 ref={editorParentRef}
150 />
151 );
152});
diff --git a/language-web/src/main/js/editor/EditorButtons.tsx b/language-web/src/main/js/editor/EditorButtons.tsx
deleted file mode 100644
index 150aa00d..00000000
--- a/language-web/src/main/js/editor/EditorButtons.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
1import type { Diagnostic } from '@codemirror/lint';
2import { observer } from 'mobx-react-lite';
3import IconButton from '@mui/material/IconButton';
4import Stack from '@mui/material/Stack';
5import ToggleButton from '@mui/material/ToggleButton';
6import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
7import CheckIcon from '@mui/icons-material/Check';
8import ErrorIcon from '@mui/icons-material/Error';
9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
10import FormatPaint from '@mui/icons-material/FormatPaint';
11import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
12import RedoIcon from '@mui/icons-material/Redo';
13import SearchIcon from '@mui/icons-material/Search';
14import UndoIcon from '@mui/icons-material/Undo';
15import WarningIcon from '@mui/icons-material/Warning';
16import React from 'react';
17
18import { useRootStore } from '../RootStore';
19
20// Exhastive switch as proven by TypeScript.
21// eslint-disable-next-line consistent-return
22function getLintIcon(severity: Diagnostic['severity'] | null) {
23 switch (severity) {
24 case 'error':
25 return <ErrorIcon fontSize="small" />;
26 case 'warning':
27 return <WarningIcon fontSize="small" />;
28 case 'info':
29 return <InfoOutlinedIcon fontSize="small" />;
30 case null:
31 return <CheckIcon fontSize="small" />;
32 }
33}
34
35export const EditorButtons = observer(() => {
36 const { editorStore } = useRootStore();
37
38 return (
39 <Stack
40 direction="row"
41 spacing={1}
42 >
43 <Stack
44 direction="row"
45 alignItems="center"
46 >
47 <IconButton
48 disabled={!editorStore.canUndo}
49 onClick={() => editorStore.undo()}
50 aria-label="Undo"
51 >
52 <UndoIcon fontSize="small" />
53 </IconButton>
54 <IconButton
55 disabled={!editorStore.canRedo}
56 onClick={() => editorStore.redo()}
57 aria-label="Redo"
58 >
59 <RedoIcon fontSize="small" />
60 </IconButton>
61 </Stack>
62 <ToggleButtonGroup
63 size="small"
64 >
65 <ToggleButton
66 selected={editorStore.showLineNumbers}
67 onClick={() => editorStore.toggleLineNumbers()}
68 aria-label="Show line numbers"
69 value="show-line-numbers"
70 >
71 <FormatListNumberedIcon fontSize="small" />
72 </ToggleButton>
73 <ToggleButton
74 selected={editorStore.showSearchPanel}
75 onClick={() => editorStore.toggleSearchPanel()}
76 aria-label="Show find/replace"
77 value="show-search-panel"
78 >
79 <SearchIcon fontSize="small" />
80 </ToggleButton>
81 <ToggleButton
82 selected={editorStore.showLintPanel}
83 onClick={() => editorStore.toggleLintPanel()}
84 aria-label="Show diagnostics panel"
85 value="show-lint-panel"
86 >
87 {getLintIcon(editorStore.highestDiagnosticLevel)}
88 </ToggleButton>
89 </ToggleButtonGroup>
90 <IconButton
91 onClick={() => editorStore.formatText()}
92 aria-label="Automatic format"
93 >
94 <FormatPaint fontSize="small" />
95 </IconButton>
96 </Stack>
97 );
98});
diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts
deleted file mode 100644
index 94ca24ea..00000000
--- a/language-web/src/main/js/editor/EditorParent.ts
+++ /dev/null
@@ -1,205 +0,0 @@
1import { styled } from '@mui/material/styles';
2
3/**
4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64.
5 *
6 * Based on
7 * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501
8 *
9 * @param color the color of the underline
10 * @returns the CSS `url()`
11 */
12function underline(color: string) {
13 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">
14 <path d="m0 3 l2 -2 l1 0 l2 2 l1 0" stroke="${color}" fill="none" stroke-width=".7"/>
15 </svg>`;
16 const svgBase64 = window.btoa(svg);
17 return `url('data:image/svg+xml;base64,${svgBase64}')`;
18}
19
20export const EditorParent = styled('div')(({ theme }) => {
21 const codeMirrorLintStyle: Record<string, unknown> = {};
22 (['error', 'warning', 'info'] as const).forEach((severity) => {
23 const color = theme.palette[severity].main;
24 codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = {
25 borderLeftColor: color,
26 };
27 codeMirrorLintStyle[`.cm-lintRange-${severity}`] = {
28 backgroundImage: underline(color),
29 };
30 });
31
32 return {
33 background: theme.palette.background.default,
34 '&, .cm-editor': {
35 height: '100%',
36 },
37 '.cm-content': {
38 padding: 0,
39 },
40 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': {
41 fontSize: 16,
42 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
43 fontFeatureSettings: '"liga", "calt"',
44 fontWeight: 400,
45 letterSpacing: 0,
46 textRendering: 'optimizeLegibility',
47 },
48 '.cm-scroller': {
49 color: theme.palette.text.secondary,
50 },
51 '.cm-gutters': {
52 background: 'rgba(255, 255, 255, 0.1)',
53 color: theme.palette.text.disabled,
54 border: 'none',
55 },
56 '.cm-specialChar': {
57 color: theme.palette.secondary.main,
58 },
59 '.cm-activeLine': {
60 background: 'rgba(0, 0, 0, 0.3)',
61 },
62 '.cm-activeLineGutter': {
63 background: 'transparent',
64 },
65 '.cm-lineNumbers .cm-activeLineGutter': {
66 color: theme.palette.text.primary,
67 },
68 '.cm-cursor, .cm-cursor-primary': {
69 borderColor: theme.palette.primary.main,
70 background: theme.palette.common.black,
71 },
72 '.cm-selectionBackground': {
73 background: '#3e4453',
74 },
75 '.cm-focused': {
76 outline: 'none',
77 '.cm-selectionBackground': {
78 background: '#3e4453',
79 },
80 },
81 '.cm-panels-top': {
82 color: theme.palette.text.secondary,
83 },
84 '.cm-panel': {
85 '&, & button, & input': {
86 fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
87 },
88 background: theme.palette.background.paper,
89 borderTop: `1px solid ${theme.palette.divider}`,
90 'button[name="close"]': {
91 background: 'transparent',
92 color: theme.palette.text.secondary,
93 cursor: 'pointer',
94 },
95 },
96 '.cm-panel.cm-panel-lint': {
97 'button[name="close"]': {
98 // Close button interferes with scrollbar, so we better hide it.
99 // The panel can still be closed from the toolbar.
100 display: 'none',
101 },
102 ul: {
103 li: {
104 borderBottom: `1px solid ${theme.palette.divider}`,
105 cursor: 'pointer',
106 },
107 '[aria-selected]': {
108 background: '#3e4453',
109 color: theme.palette.text.primary,
110 },
111 '&:focus [aria-selected]': {
112 background: theme.palette.primary.main,
113 color: theme.palette.primary.contrastText,
114 },
115 },
116 },
117 '.cm-foldPlaceholder': {
118 background: theme.palette.background.paper,
119 borderColor: theme.palette.text.disabled,
120 color: theme.palette.text.secondary,
121 },
122 '.cmt-comment': {
123 fontStyle: 'italic',
124 color: theme.palette.text.disabled,
125 },
126 '.cmt-number': {
127 color: '#6188a6',
128 },
129 '.cmt-string': {
130 color: theme.palette.secondary.dark,
131 },
132 '.cmt-keyword': {
133 color: theme.palette.primary.main,
134 },
135 '.cmt-typeName, .cmt-macroName, .cmt-atom': {
136 color: theme.palette.text.primary,
137 },
138 '.cmt-variableName': {
139 color: '#c8ae9d',
140 },
141 '.cmt-problem-node': {
142 '&, & .cmt-variableName': {
143 color: theme.palette.text.secondary,
144 },
145 },
146 '.cmt-problem-individual': {
147 '&, & .cmt-variableName': {
148 color: theme.palette.text.primary,
149 },
150 },
151 '.cmt-problem-abstract, .cmt-problem-new': {
152 fontStyle: 'italic',
153 },
154 '.cmt-problem-containment': {
155 fontWeight: 700,
156 },
157 '.cmt-problem-error': {
158 '&, & .cmt-typeName': {
159 color: theme.palette.error.main,
160 },
161 },
162 '.cmt-problem-builtin': {
163 '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': {
164 color: theme.palette.primary.main,
165 fontWeight: 400,
166 fontStyle: 'normal',
167 },
168 },
169 '.cm-tooltip-autocomplete': {
170 background: theme.palette.background.paper,
171 boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%),
172 0px 4px 5px 0px rgb(0 0 0 / 14%),
173 0px 1px 10px 0px rgb(0 0 0 / 12%)`,
174 '.cm-completionIcon': {
175 color: theme.palette.text.secondary,
176 },
177 '.cm-completionLabel': {
178 color: theme.palette.text.primary,
179 },
180 '.cm-completionDetail': {
181 color: theme.palette.text.secondary,
182 fontStyle: 'normal',
183 },
184 '[aria-selected]': {
185 background: `${theme.palette.primary.main} !important`,
186 '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': {
187 color: theme.palette.primary.contrastText,
188 },
189 },
190 },
191 '.cm-completionIcon': {
192 width: 16,
193 padding: 0,
194 marginRight: '0.5em',
195 textAlign: 'center',
196 },
197 ...codeMirrorLintStyle,
198 '.cm-problem-write': {
199 background: 'rgba(255, 255, 128, 0.3)',
200 },
201 '.cm-problem-read': {
202 background: 'rgba(255, 255, 255, 0.15)',
203 },
204 };
205});
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts
deleted file mode 100644
index 5760de28..00000000
--- a/language-web/src/main/js/editor/EditorStore.ts
+++ /dev/null
@@ -1,289 +0,0 @@
1import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
2import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
3import { defaultKeymap, indentWithTab } from '@codemirror/commands';
4import { commentKeymap } from '@codemirror/comment';
5import { foldGutter, foldKeymap } from '@codemirror/fold';
6import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
7import { classHighlightStyle } from '@codemirror/highlight';
8import {
9 history,
10 historyKeymap,
11 redo,
12 redoDepth,
13 undo,
14 undoDepth,
15} from '@codemirror/history';
16import { indentOnInput } from '@codemirror/language';
17import {
18 Diagnostic,
19 lintKeymap,
20 setDiagnostics,
21} from '@codemirror/lint';
22import { bracketMatching } from '@codemirror/matchbrackets';
23import { rectangularSelection } from '@codemirror/rectangular-selection';
24import { searchConfig, searchKeymap } from '@codemirror/search';
25import {
26 EditorState,
27 StateCommand,
28 StateEffect,
29 Transaction,
30 TransactionSpec,
31} from '@codemirror/state';
32import {
33 drawSelection,
34 EditorView,
35 highlightActiveLine,
36 highlightSpecialChars,
37 keymap,
38} from '@codemirror/view';
39import {
40 makeAutoObservable,
41 observable,
42 reaction,
43} from 'mobx';
44
45import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences';
46import { problemLanguageSupport } from '../language/problemLanguageSupport';
47import {
48 IHighlightRange,
49 semanticHighlighting,
50 setSemanticHighlighting,
51} from './semanticHighlighting';
52import type { ThemeStore } from '../theme/ThemeStore';
53import { getLogger } from '../utils/logger';
54import { XtextClient } from '../xtext/XtextClient';
55
56const log = getLogger('editor.EditorStore');
57
58export class EditorStore {
59 private readonly themeStore;
60
61 state: EditorState;
62
63 private readonly client: XtextClient;
64
65 showLineNumbers = false;
66
67 showSearchPanel = false;
68
69 showLintPanel = false;
70
71 errorCount = 0;
72
73 warningCount = 0;
74
75 infoCount = 0;
76
77 private readonly defaultDispatcher = (tr: Transaction): void => {
78 this.onTransaction(tr);
79 };
80
81 private dispatcher = this.defaultDispatcher;
82
83 constructor(initialValue: string, themeStore: ThemeStore) {
84 this.themeStore = themeStore;
85 this.state = EditorState.create({
86 doc: initialValue,
87 extensions: [
88 autocompletion({
89 activateOnTyping: true,
90 override: [
91 (context) => this.client.contentAssist(context),
92 ],
93 }),
94 classHighlightStyle.extension,
95 closeBrackets(),
96 bracketMatching(),
97 drawSelection(),
98 EditorState.allowMultipleSelections.of(true),
99 EditorView.theme({}, {
100 dark: this.themeStore.darkMode,
101 }),
102 findOccurrences,
103 highlightActiveLine(),
104 highlightActiveLineGutter(),
105 highlightSpecialChars(),
106 history(),
107 indentOnInput(),
108 rectangularSelection(),
109 searchConfig({
110 top: true,
111 matchCase: true,
112 }),
113 semanticHighlighting,
114 // We add the gutters to `extensions` in the order we want them to appear.
115 lineNumbers(),
116 foldGutter(),
117 keymap.of([
118 { key: 'Mod-Shift-f', run: () => this.formatText() },
119 ...closeBracketsKeymap,
120 ...commentKeymap,
121 ...completionKeymap,
122 ...foldKeymap,
123 ...historyKeymap,
124 indentWithTab,
125 // Override keys in `lintKeymap` to go through the `EditorStore`.
126 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
127 ...lintKeymap,
128 // Override keys in `searchKeymap` to go through the `EditorStore`.
129 { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' },
130 { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' },
131 ...searchKeymap,
132 ...defaultKeymap,
133 ]),
134 problemLanguageSupport(),
135 ],
136 });
137 this.client = new XtextClient(this);
138 reaction(
139 () => this.themeStore.darkMode,
140 (darkMode) => {
141 log.debug('Update editor dark mode', darkMode);
142 this.dispatch({
143 effects: [
144 StateEffect.appendConfig.of(EditorView.theme({}, {
145 dark: darkMode,
146 })),
147 ],
148 });
149 },
150 );
151 makeAutoObservable(this, {
152 state: observable.ref,
153 });
154 }
155
156 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void {
157 this.dispatcher = newDispatcher || this.defaultDispatcher;
158 }
159
160 onTransaction(tr: Transaction): void {
161 log.trace('Editor transaction', tr);
162 this.state = tr.state;
163 this.client.onTransaction(tr);
164 }
165
166 dispatch(...specs: readonly TransactionSpec[]): void {
167 this.dispatcher(this.state.update(...specs));
168 }
169
170 doStateCommand(command: StateCommand): boolean {
171 return command({
172 state: this.state,
173 dispatch: this.dispatcher,
174 });
175 }
176
177 updateDiagnostics(diagnostics: Diagnostic[]): void {
178 this.dispatch(setDiagnostics(this.state, diagnostics));
179 this.errorCount = 0;
180 this.warningCount = 0;
181 this.infoCount = 0;
182 diagnostics.forEach(({ severity }) => {
183 switch (severity) {
184 case 'error':
185 this.errorCount += 1;
186 break;
187 case 'warning':
188 this.warningCount += 1;
189 break;
190 case 'info':
191 this.infoCount += 1;
192 break;
193 }
194 });
195 }
196
197 get highestDiagnosticLevel(): Diagnostic['severity'] | null {
198 if (this.errorCount > 0) {
199 return 'error';
200 }
201 if (this.warningCount > 0) {
202 return 'warning';
203 }
204 if (this.infoCount > 0) {
205 return 'info';
206 }
207 return null;
208 }
209
210 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
211 this.dispatch(setSemanticHighlighting(ranges));
212 }
213
214 updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void {
215 this.dispatch(setOccurrences(write, read));
216 }
217
218 /**
219 * @returns `true` if there is history to undo
220 */
221 get canUndo(): boolean {
222 return undoDepth(this.state) > 0;
223 }
224
225 // eslint-disable-next-line class-methods-use-this
226 undo(): void {
227 log.debug('Undo', this.doStateCommand(undo));
228 }
229
230 /**
231 * @returns `true` if there is history to redo
232 */
233 get canRedo(): boolean {
234 return redoDepth(this.state) > 0;
235 }
236
237 // eslint-disable-next-line class-methods-use-this
238 redo(): void {
239 log.debug('Redo', this.doStateCommand(redo));
240 }
241
242 toggleLineNumbers(): void {
243 this.showLineNumbers = !this.showLineNumbers;
244 log.debug('Show line numbers', this.showLineNumbers);
245 }
246
247 /**
248 * Sets whether the CodeMirror search panel should be open.
249 *
250 * This method can be used as a CodeMirror command,
251 * because it returns `false` if it didn't execute,
252 * allowing other commands for the same keybind to run instead.
253 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
254 * commands from `'@codemirror/search'`.
255 *
256 * @param newShosSearchPanel whether we should show the search panel
257 * @returns `true` if the state was changed, `false` otherwise
258 */
259 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
260 if (this.showSearchPanel === newShowSearchPanel) {
261 return false;
262 }
263 this.showSearchPanel = newShowSearchPanel;
264 log.debug('Show search panel', this.showSearchPanel);
265 return true;
266 }
267
268 toggleSearchPanel(): void {
269 this.setSearchPanelOpen(!this.showSearchPanel);
270 }
271
272 setLintPanelOpen(newShowLintPanel: boolean): boolean {
273 if (this.showLintPanel === newShowLintPanel) {
274 return false;
275 }
276 this.showLintPanel = newShowLintPanel;
277 log.debug('Show lint panel', this.showLintPanel);
278 return true;
279 }
280
281 toggleLintPanel(): void {
282 this.setLintPanelOpen(!this.showLintPanel);
283 }
284
285 formatText(): boolean {
286 this.client.formatText();
287 return true;
288 }
289}
diff --git a/language-web/src/main/js/editor/GenerateButton.tsx b/language-web/src/main/js/editor/GenerateButton.tsx
deleted file mode 100644
index 3834cec4..00000000
--- a/language-web/src/main/js/editor/GenerateButton.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
1import { observer } from 'mobx-react-lite';
2import Button from '@mui/material/Button';
3import PlayArrowIcon from '@mui/icons-material/PlayArrow';
4import React from 'react';
5
6import { useRootStore } from '../RootStore';
7
8const GENERATE_LABEL = 'Generate';
9
10export const GenerateButton = observer(() => {
11 const { editorStore } = useRootStore();
12 const { errorCount, warningCount } = editorStore;
13
14 const diagnostics: string[] = [];
15 if (errorCount > 0) {
16 diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
17 }
18 if (warningCount > 0) {
19 diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
20 }
21 const summary = diagnostics.join(' and ');
22
23 if (errorCount > 0) {
24 return (
25 <Button
26 variant="outlined"
27 color="error"
28 onClick={() => editorStore.toggleLintPanel()}
29 >
30 {summary}
31 </Button>
32 );
33 }
34
35 return (
36 <Button
37 variant="outlined"
38 color={warningCount > 0 ? 'warning' : 'primary'}
39 startIcon={<PlayArrowIcon />}
40 >
41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
42 </Button>
43 );
44});
diff --git a/language-web/src/main/js/editor/decorationSetExtension.ts b/language-web/src/main/js/editor/decorationSetExtension.ts
deleted file mode 100644
index 2d630c20..00000000
--- a/language-web/src/main/js/editor/decorationSetExtension.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state';
2import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
3
4export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec;
5
6export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] {
7 const setEffect = StateEffect.define<DecorationSet>();
8 const field = StateField.define<DecorationSet>({
9 create() {
10 return Decoration.none;
11 },
12 update(currentDecorations, transaction) {
13 let newDecorations: DecorationSet | null = null;
14 transaction.effects.forEach((effect) => {
15 if (effect.is(setEffect)) {
16 newDecorations = effect.value;
17 }
18 });
19 if (newDecorations === null) {
20 if (transaction.docChanged) {
21 return currentDecorations.map(transaction.changes);
22 }
23 return currentDecorations;
24 }
25 return newDecorations;
26 },
27 provide: (f) => EditorView.decorations.from(f),
28 });
29
30 function transactionSpecFactory(decorations: DecorationSet) {
31 return {
32 effects: [
33 setEffect.of(decorations),
34 ],
35 };
36 }
37
38 return [transactionSpecFactory, field];
39}
diff --git a/language-web/src/main/js/editor/findOccurrences.ts b/language-web/src/main/js/editor/findOccurrences.ts
deleted file mode 100644
index 92102746..00000000
--- a/language-web/src/main/js/editor/findOccurrences.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { Range, RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IOccurrence {
8 from: number;
9
10 to: number;
11}
12
13const [setOccurrencesInteral, findOccurrences] = decorationSetExtension();
14
15const writeDecoration = Decoration.mark({
16 class: 'cm-problem-write',
17});
18
19const readDecoration = Decoration.mark({
20 class: 'cm-problem-read',
21});
22
23export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec {
24 const decorations: Range<Decoration>[] = [];
25 write.forEach(({ from, to }) => {
26 decorations.push(writeDecoration.range(from, to));
27 });
28 read.forEach(({ from, to }) => {
29 decorations.push(readDecoration.range(from, to));
30 });
31 const rangeSet = RangeSet.of(decorations, true);
32 return setOccurrencesInteral(rangeSet);
33}
34
35export { findOccurrences };
diff --git a/language-web/src/main/js/editor/semanticHighlighting.ts b/language-web/src/main/js/editor/semanticHighlighting.ts
deleted file mode 100644
index 2aed421b..00000000
--- a/language-web/src/main/js/editor/semanticHighlighting.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IHighlightRange {
8 from: number;
9
10 to: number;
11
12 classes: string[];
13}
14
15const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension();
16
17export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec {
18 const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({
19 class: classes.map((c) => `cmt-problem-${c}`).join(' '),
20 }).range(from, to)), true);
21 return setSemanticHighlightingInternal(rangeSet);
22}
23
24export { semanticHighlighting };
diff --git a/language-web/src/main/js/global.d.ts b/language-web/src/main/js/global.d.ts
deleted file mode 100644
index 0533a46e..00000000
--- a/language-web/src/main/js/global.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
1declare const DEBUG: boolean;
2
3declare const PACKAGE_NAME: string;
4
5declare const PACKAGE_VERSION: string;
6
7declare module '*.module.scss' {
8 const cssVariables: { [key in string]?: string };
9 // eslint-disable-next-line import/no-default-export
10 export default cssVariables;
11}
diff --git a/language-web/src/main/js/index.tsx b/language-web/src/main/js/index.tsx
deleted file mode 100644
index d368c9ba..00000000
--- a/language-web/src/main/js/index.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
1import React from 'react';
2import { render } from 'react-dom';
3import CssBaseline from '@mui/material/CssBaseline';
4
5import { App } from './App';
6import { RootStore, RootStoreProvider } from './RootStore';
7import { ThemeProvider } from './theme/ThemeProvider';
8
9import '../css/index.scss';
10
11const initialValue = `class Family {
12 contains Person[] members
13}
14
15class Person {
16 Person[] children opposite parent
17 Person[0..1] parent opposite children
18 int age
19 TaxStatus taxStatus
20}
21
22enum TaxStatus {
23 child, student, adult, retired
24}
25
26% A child cannot have any dependents.
27pred invalidTaxStatus(Person p) <->
28 taxStatus(p, child),
29 children(p, _q)
30 ; taxStatus(p, retired),
31 parent(p, q),
32 !taxStatus(q, retired).
33
34direct rule createChild(p):
35 children(p, newPerson) = unknown,
36 equals(newPerson, newPerson) = unknown
37 ~> new q,
38 children(p, q) = true,
39 taxStatus(q, child) = true.
40
41indiv family.
42Family(family).
43members(family, anne).
44members(family, bob).
45members(family, ciri).
46children(anne, ciri).
47?children(bob, ciri).
48default children(ciri, *): false.
49taxStatus(anne, adult).
50age(anne, 35).
51bobAge: 27.
52age(bob, bobAge).
53!age(ciri, bobAge).
54
55scope Family = 1, Person += 5..10.
56`;
57
58const rootStore = new RootStore(initialValue);
59
60const app = (
61 <RootStoreProvider rootStore={rootStore}>
62 <ThemeProvider>
63 <CssBaseline />
64 <App />
65 </ThemeProvider>
66 </RootStoreProvider>
67);
68
69render(app, document.getElementById('app'));
diff --git a/language-web/src/main/js/language/folding.ts b/language-web/src/main/js/language/folding.ts
deleted file mode 100644
index 5d51f796..00000000
--- a/language-web/src/main/js/language/folding.ts
+++ /dev/null
@@ -1,115 +0,0 @@
1import { EditorState } from '@codemirror/state';
2import type { SyntaxNode } from '@lezer/common';
3
4export type FoldRange = { from: number, to: number };
5
6/**
7 * Folds a block comment between its delimiters.
8 *
9 * @param node the node to fold
10 * @returns the folding range or `null` is there is nothing to fold
11 */
12export function foldBlockComment(node: SyntaxNode): FoldRange {
13 return {
14 from: node.from + 2,
15 to: node.to - 2,
16 };
17}
18
19/**
20 * Folds a declaration after the first element if it appears on the opening line,
21 * otherwise folds after the opening keyword.
22 *
23 * @example
24 * First element on the opening line:
25 * ```
26 * scope Family = 1,
27 * Person += 5..10.
28 * ```
29 * becomes
30 * ```
31 * scope Family = 1,[...].
32 * ```
33 *
34 * @example
35 * First element not on the opening line:
36 * ```
37 * scope Family
38 * = 1,
39 * Person += 5..10.
40 * ```
41 * becomes
42 * ```
43 * scope [...].
44 * ```
45 *
46 * @param node the node to fold
47 * @param state the editor state
48 * @returns the folding range or `null` is there is nothing to fold
49 */
50export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null {
51 const { firstChild: open, lastChild: close } = node;
52 if (open === null || close === null) {
53 return null;
54 }
55 const { cursor } = open;
56 const lineEnd = state.doc.lineAt(open.from).to;
57 let foldFrom = open.to;
58 while (cursor.next() && cursor.from < lineEnd) {
59 if (cursor.type.name === ',') {
60 foldFrom = cursor.to;
61 break;
62 }
63 }
64 return {
65 from: foldFrom,
66 to: close.from,
67 };
68}
69
70/**
71 * Folds a node only if it has at least one sibling of the same type.
72 *
73 * The folding range will be the entire `node`.
74 *
75 * @param node the node to fold
76 * @returns the folding range or `null` is there is nothing to fold
77 */
78function foldWithSibling(node: SyntaxNode): FoldRange | null {
79 const { parent } = node;
80 if (parent === null) {
81 return null;
82 }
83 const { firstChild } = parent;
84 if (firstChild === null) {
85 return null;
86 }
87 const { cursor } = firstChild;
88 let nSiblings = 0;
89 while (cursor.nextSibling()) {
90 if (cursor.type === node.type) {
91 nSiblings += 1;
92 }
93 if (nSiblings >= 2) {
94 return {
95 from: node.from,
96 to: node.to,
97 };
98 }
99 }
100 return null;
101}
102
103export function foldWholeNode(node: SyntaxNode): FoldRange {
104 return {
105 from: node.from,
106 to: node.to,
107 };
108}
109
110export function foldConjunction(node: SyntaxNode): FoldRange | null {
111 if (node.parent?.type?.name === 'PredicateBody') {
112 return foldWithSibling(node);
113 }
114 return foldWholeNode(node);
115}
diff --git a/language-web/src/main/js/language/indentation.ts b/language-web/src/main/js/language/indentation.ts
deleted file mode 100644
index 6d36ed3b..00000000
--- a/language-web/src/main/js/language/indentation.ts
+++ /dev/null
@@ -1,87 +0,0 @@
1import { TreeIndentContext } from '@codemirror/language';
2
3/**
4 * Finds the `from` of first non-skipped token, if any,
5 * after the opening keyword in the first line of the declaration.
6 *
7 * Based on
8 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246
9 *
10 * @param context the indentation context
11 * @returns the alignment or `null` if there is no token after the opening keyword
12 */
13function findAlignmentAfterOpening(context: TreeIndentContext): number | null {
14 const {
15 node: tree,
16 simulatedBreak,
17 } = context;
18 const openingToken = tree.childAfter(tree.from);
19 if (openingToken === null) {
20 return null;
21 }
22 const openingLine = context.state.doc.lineAt(openingToken.from);
23 const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from
24 ? openingLine.to
25 : Math.min(openingLine.to, simulatedBreak);
26 const { cursor } = openingToken;
27 while (cursor.next() && cursor.from < lineEnd) {
28 if (!cursor.type.isSkipped) {
29 return cursor.from;
30 }
31 }
32 return null;
33}
34
35/**
36 * Indents text after declarations by a single unit if it begins on a new line,
37 * otherwise it aligns with the text after the declaration.
38 *
39 * Based on
40 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275
41 *
42 * @example
43 * Result with no hanging indent (indent unit = 2 spaces, units = 1):
44 * ```
45 * scope
46 * Family = 1,
47 * Person += 5..10.
48 * ```
49 *
50 * @example
51 * Result with hanging indent:
52 * ```
53 * scope Family = 1,
54 * Person += 5..10.
55 * ```
56 *
57 * @param context the indentation context
58 * @param units the number of units to indent
59 * @returns the desired indentation level
60 */
61function indentDeclarationStrategy(context: TreeIndentContext, units: number): number {
62 const alignment = findAlignmentAfterOpening(context);
63 if (alignment !== null) {
64 return context.column(alignment);
65 }
66 return context.baseIndent + units * context.unit;
67}
68
69export function indentBlockComment(): number {
70 // Do not indent.
71 return -1;
72}
73
74export function indentDeclaration(context: TreeIndentContext): number {
75 return indentDeclarationStrategy(context, 1);
76}
77
78export function indentPredicateOrRule(context: TreeIndentContext): number {
79 const clauseIndent = indentDeclarationStrategy(context, 1);
80 if (/^\s+[;.]/.exec(context.textAfter) !== null) {
81 return clauseIndent - 2;
82 }
83 if (/^\s+(~>)/.exec(context.textAfter) !== null) {
84 return clauseIndent - 3;
85 }
86 return clauseIndent;
87}
diff --git a/language-web/src/main/js/language/problem.grammar b/language-web/src/main/js/language/problem.grammar
deleted file mode 100644
index bccc2e31..00000000
--- a/language-web/src/main/js/language/problem.grammar
+++ /dev/null
@@ -1,149 +0,0 @@
1@detectDelim
2
3@external prop implicitCompletion from '../../../../src/main/js/language/props.ts'
4
5@top Problem { statement* }
6
7statement {
8 ProblemDeclaration {
9 ckw<"problem"> QualifiedName "."
10 } |
11 ClassDefinition {
12 ckw<"abstract">? ckw<"class"> RelationName
13 (ckw<"extends"> sep<",", RelationName>)?
14 (ClassBody { "{" ReferenceDeclaration* "}" } | ".")
15 } |
16 EnumDefinition {
17 ckw<"enum"> RelationName
18 (EnumBody { "{" sep<",", IndividualNodeName> "}" } | ".")
19 } |
20 PredicateDefinition {
21 (ckw<"error"> ckw<"pred">? | ckw<"direct">? ckw<"pred">)
22 RelationName ParameterList<Parameter>?
23 PredicateBody { ("<->" sep<OrOp, Conjunction>)? "." }
24 } |
25 RuleDefinition {
26 ckw<"direct">? ckw<"rule">
27 RuleName ParameterList<Parameter>?
28 RuleBody { ":" sep<OrOp, Conjunction> "~>" sep<OrOp, Action> "." }
29 } |
30 Assertion {
31 kw<"default">? (NotOp | UnknownOp)? RelationName
32 ParameterList<AssertionArgument> (":" LogicValue)? "."
33 } |
34 NodeValueAssertion {
35 IndividualNodeName ":" Constant "."
36 } |
37 IndividualDeclaration {
38 ckw<"indiv"> sep<",", IndividualNodeName> "."
39 } |
40 ScopeDeclaration {
41 kw<"scope"> sep<",", ScopeElement> "."
42 }
43}
44
45ReferenceDeclaration {
46 (kw<"refers"> | kw<"contains">)?
47 RelationName
48 RelationName
49 ( "[" Multiplicity? "]" )?
50 (kw<"opposite"> RelationName)?
51 ";"?
52}
53
54Parameter { RelationName? VariableName }
55
56Conjunction { ("," | Literal)+ }
57
58OrOp { ";" }
59
60Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? }
61
62Atom { RelationName "+"? ParameterList<Argument> }
63
64Action { ("," | ActionLiteral)+ }
65
66ActionLiteral {
67 ckw<"new"> VariableName |
68 ckw<"delete"> VariableName |
69 Literal
70}
71
72Argument { VariableName | Constant }
73
74AssertionArgument { NodeName | StarArgument | Constant }
75
76Constant { Real | String }
77
78LogicValue {
79 ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error">
80}
81
82ScopeElement { RelationName ("=" | "+=") Multiplicity }
83
84Multiplicity { (IntMult "..")? (IntMult | StarMult)}
85
86RelationName { QualifiedName }
87
88RuleName { QualifiedName }
89
90IndividualNodeName { QualifiedName }
91
92VariableName { QualifiedName }
93
94NodeName { QualifiedName }
95
96QualifiedName[implicitCompletion=true] { identifier ("::" identifier)* }
97
98kw<term> { @specialize[@name={term},implicitCompletion=true]<identifier, term> }
99
100ckw<term> { @extend[@name={term},implicitCompletion=true]<identifier, term> }
101
102ParameterList<content> { "(" sep<",", content> ")" }
103
104sep<separator, content> { sep1<separator, content>? }
105
106sep1<separator, content> { content (separator content)* }
107
108@skip { LineComment | BlockComment | whitespace }
109
110@tokens {
111 whitespace { std.whitespace+ }
112
113 LineComment { ("//" | "%") ![\n]* }
114
115 BlockComment { "/*" blockCommentRest }
116
117 blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar }
118
119 blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest }
120
121 @precedence { BlockComment, LineComment }
122
123 identifier { $[A-Za-z_] $[a-zA-Z0-9_]* }
124
125 int { $[0-9]+ }
126
127 IntMult { int }
128
129 StarMult { "*" }
130
131 Real { "-"? (exponential | int ("." (int | exponential))?) }
132
133 exponential { int ("e" | "E") ("+" | "-")? int }
134
135 String {
136 "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" |
137 "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\""
138 }
139
140 NotOp { "!" }
141
142 UnknownOp { "?" }
143
144 StarArgument { "*" }
145
146 "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" "~>"
147}
148
149@detectDelim
diff --git a/language-web/src/main/js/language/problemLanguageSupport.ts b/language-web/src/main/js/language/problemLanguageSupport.ts
deleted file mode 100644
index 6508a2c0..00000000
--- a/language-web/src/main/js/language/problemLanguageSupport.ts
+++ /dev/null
@@ -1,92 +0,0 @@
1import { styleTags, tags as t } from '@codemirror/highlight';
2import {
3 foldInside,
4 foldNodeProp,
5 indentNodeProp,
6 indentUnit,
7 LanguageSupport,
8 LRLanguage,
9} from '@codemirror/language';
10import { LRParser } from '@lezer/lr';
11
12import { parser } from '../../../../build/generated/sources/lezer/problem';
13import {
14 foldBlockComment,
15 foldConjunction,
16 foldDeclaration,
17 foldWholeNode,
18} from './folding';
19import {
20 indentBlockComment,
21 indentDeclaration,
22 indentPredicateOrRule,
23} from './indentation';
24
25const parserWithMetadata = (parser as LRParser).configure({
26 props: [
27 styleTags({
28 LineComment: t.lineComment,
29 BlockComment: t.blockComment,
30 'problem class enum pred rule indiv scope': t.definitionKeyword,
31 'abstract extends refers contains opposite error direct default': t.modifier,
32 'true false unknown error': t.keyword,
33 'new delete': t.operatorKeyword,
34 NotOp: t.keyword,
35 UnknownOp: t.keyword,
36 OrOp: t.keyword,
37 StarArgument: t.keyword,
38 'IntMult StarMult Real': t.number,
39 StarMult: t.number,
40 String: t.string,
41 'RelationName/QualifiedName': t.typeName,
42 'RuleName/QualifiedName': t.macroName,
43 'IndividualNodeName/QualifiedName': t.atom,
44 'VariableName/QualifiedName': t.variableName,
45 '{ }': t.brace,
46 '( )': t.paren,
47 '[ ]': t.squareBracket,
48 '. .. , :': t.separator,
49 '<-> ~>': t.definitionOperator,
50 }),
51 indentNodeProp.add({
52 ProblemDeclaration: indentDeclaration,
53 UniqueDeclaration: indentDeclaration,
54 ScopeDeclaration: indentDeclaration,
55 PredicateBody: indentPredicateOrRule,
56 RuleBody: indentPredicateOrRule,
57 BlockComment: indentBlockComment,
58 }),
59 foldNodeProp.add({
60 ClassBody: foldInside,
61 EnumBody: foldInside,
62 ParameterList: foldInside,
63 PredicateBody: foldInside,
64 RuleBody: foldInside,
65 Conjunction: foldConjunction,
66 Action: foldWholeNode,
67 UniqueDeclaration: foldDeclaration,
68 ScopeDeclaration: foldDeclaration,
69 BlockComment: foldBlockComment,
70 }),
71 ],
72});
73
74const problemLanguage = LRLanguage.define({
75 parser: parserWithMetadata,
76 languageData: {
77 commentTokens: {
78 block: {
79 open: '/*',
80 close: '*/',
81 },
82 line: '%',
83 },
84 indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.|~>)$/,
85 },
86});
87
88export function problemLanguageSupport(): LanguageSupport {
89 return new LanguageSupport(problemLanguage, [
90 indentUnit.of(' '),
91 ]);
92}
diff --git a/language-web/src/main/js/language/props.ts b/language-web/src/main/js/language/props.ts
deleted file mode 100644
index 8e488bf5..00000000
--- a/language-web/src/main/js/language/props.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { NodeProp } from '@lezer/common';
2
3export const implicitCompletion = new NodeProp({
4 deserialize(s: string) {
5 return s === 'true';
6 },
7});
diff --git a/language-web/src/main/js/theme/EditorTheme.ts b/language-web/src/main/js/theme/EditorTheme.ts
deleted file mode 100644
index 1b0dd5de..00000000
--- a/language-web/src/main/js/theme/EditorTheme.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import type { PaletteMode } from '@mui/material';
2
3import cssVariables from '../../css/themeVariables.module.scss';
4
5export enum EditorTheme {
6 Light,
7 Dark,
8}
9
10export class EditorThemeData {
11 className: string;
12
13 paletteMode: PaletteMode;
14
15 toggleDarkMode: EditorTheme;
16
17 foreground!: string;
18
19 foregroundHighlight!: string;
20
21 background!: string;
22
23 primary!: string;
24
25 secondary!: string;
26
27 constructor(className: string, paletteMode: PaletteMode, toggleDarkMode: EditorTheme) {
28 this.className = className;
29 this.paletteMode = paletteMode;
30 this.toggleDarkMode = toggleDarkMode;
31 Reflect.ownKeys(this).forEach((key) => {
32 if (!Reflect.get(this, key)) {
33 const cssKey = `${this.className}--${key.toString()}`;
34 if (cssKey in cssVariables) {
35 Reflect.set(this, key, cssVariables[cssKey]);
36 }
37 }
38 });
39 }
40}
41
42export const DEFAULT_THEME = EditorTheme.Dark;
43
44export const EDITOR_THEMES: { [key in EditorTheme]: EditorThemeData } = {
45 [EditorTheme.Light]: new EditorThemeData('light', 'light', EditorTheme.Dark),
46 [EditorTheme.Dark]: new EditorThemeData('dark', 'dark', EditorTheme.Light),
47};
diff --git a/language-web/src/main/js/theme/ThemeProvider.tsx b/language-web/src/main/js/theme/ThemeProvider.tsx
deleted file mode 100644
index f5b50be1..00000000
--- a/language-web/src/main/js/theme/ThemeProvider.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
1import { observer } from 'mobx-react-lite';
2import { ThemeProvider as MaterialUiThemeProvider } from '@mui/material/styles';
3import React from 'react';
4
5import { useRootStore } from '../RootStore';
6
7export const ThemeProvider: React.FC = observer(({ children }) => {
8 const { themeStore } = useRootStore();
9
10 return (
11 <MaterialUiThemeProvider theme={themeStore.materialUiTheme}>
12 {children}
13 </MaterialUiThemeProvider>
14 );
15});
diff --git a/language-web/src/main/js/theme/ThemeStore.ts b/language-web/src/main/js/theme/ThemeStore.ts
deleted file mode 100644
index ffaf6dde..00000000
--- a/language-web/src/main/js/theme/ThemeStore.ts
+++ /dev/null
@@ -1,64 +0,0 @@
1import { makeAutoObservable } from 'mobx';
2import {
3 Theme,
4 createTheme,
5 responsiveFontSizes,
6} from '@mui/material/styles';
7
8import {
9 EditorTheme,
10 EditorThemeData,
11 DEFAULT_THEME,
12 EDITOR_THEMES,
13} from './EditorTheme';
14
15export class ThemeStore {
16 currentTheme: EditorTheme = DEFAULT_THEME;
17
18 constructor() {
19 makeAutoObservable(this);
20 }
21
22 toggleDarkMode(): void {
23 this.currentTheme = this.currentThemeData.toggleDarkMode;
24 }
25
26 private get currentThemeData(): EditorThemeData {
27 return EDITOR_THEMES[this.currentTheme];
28 }
29
30 get materialUiTheme(): Theme {
31 const themeData = this.currentThemeData;
32 const materialUiTheme = createTheme({
33 palette: {
34 mode: themeData.paletteMode,
35 background: {
36 default: themeData.background,
37 paper: themeData.background,
38 },
39 primary: {
40 main: themeData.primary,
41 },
42 secondary: {
43 main: themeData.secondary,
44 },
45 error: {
46 main: themeData.secondary,
47 },
48 text: {
49 primary: themeData.foregroundHighlight,
50 secondary: themeData.foreground,
51 },
52 },
53 });
54 return responsiveFontSizes(materialUiTheme);
55 }
56
57 get darkMode(): boolean {
58 return this.currentThemeData.paletteMode === 'dark';
59 }
60
61 get className(): string {
62 return this.currentThemeData.className;
63 }
64}
diff --git a/language-web/src/main/js/utils/ConditionVariable.ts b/language-web/src/main/js/utils/ConditionVariable.ts
deleted file mode 100644
index 0910dfa6..00000000
--- a/language-web/src/main/js/utils/ConditionVariable.ts
+++ /dev/null
@@ -1,64 +0,0 @@
1import { getLogger } from './logger';
2import { PendingTask } from './PendingTask';
3
4const log = getLogger('utils.ConditionVariable');
5
6export type Condition = () => boolean;
7
8export class ConditionVariable {
9 condition: Condition;
10
11 defaultTimeout: number;
12
13 listeners: PendingTask<void>[] = [];
14
15 constructor(condition: Condition, defaultTimeout = 0) {
16 this.condition = condition;
17 this.defaultTimeout = defaultTimeout;
18 }
19
20 async waitFor(timeoutMs: number | null = null): Promise<void> {
21 if (this.condition()) {
22 return;
23 }
24 const timeoutOrDefault = timeoutMs || this.defaultTimeout;
25 let nowMs = Date.now();
26 const endMs = nowMs + timeoutOrDefault;
27 while (!this.condition() && nowMs < endMs) {
28 const remainingMs = endMs - nowMs;
29 const promise = new Promise<void>((resolve, reject) => {
30 if (this.condition()) {
31 resolve();
32 return;
33 }
34 const task = new PendingTask(resolve, reject, remainingMs);
35 this.listeners.push(task);
36 });
37 // We must keep waiting until the update has completed,
38 // so the tasks can't be started in parallel.
39 // eslint-disable-next-line no-await-in-loop
40 await promise;
41 nowMs = Date.now();
42 }
43 if (!this.condition()) {
44 log.error('Condition still does not hold after', timeoutOrDefault, 'ms');
45 throw new Error('Failed to wait for condition');
46 }
47 }
48
49 notifyAll(): void {
50 this.clearListenersWith((listener) => listener.resolve());
51 }
52
53 rejectAll(error: unknown): void {
54 this.clearListenersWith((listener) => listener.reject(error));
55 }
56
57 private clearListenersWith(callback: (listener: PendingTask<void>) => void) {
58 // Copy `listeners` so that we don't get into a race condition
59 // if one of the listeners adds another listener.
60 const { listeners } = this;
61 this.listeners = [];
62 listeners.forEach(callback);
63 }
64}
diff --git a/language-web/src/main/js/utils/PendingTask.ts b/language-web/src/main/js/utils/PendingTask.ts
deleted file mode 100644
index 51b79fb0..00000000
--- a/language-web/src/main/js/utils/PendingTask.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import { getLogger } from './logger';
2
3const log = getLogger('utils.PendingTask');
4
5export class PendingTask<T> {
6 private readonly resolveCallback: (value: T) => void;
7
8 private readonly rejectCallback: (reason?: unknown) => void;
9
10 private resolved = false;
11
12 private timeout: number | null;
13
14 constructor(
15 resolveCallback: (value: T) => void,
16 rejectCallback: (reason?: unknown) => void,
17 timeoutMs?: number,
18 timeoutCallback?: () => void,
19 ) {
20 this.resolveCallback = resolveCallback;
21 this.rejectCallback = rejectCallback;
22 if (timeoutMs) {
23 this.timeout = setTimeout(() => {
24 if (!this.resolved) {
25 this.reject(new Error('Request timed out'));
26 if (timeoutCallback) {
27 timeoutCallback();
28 }
29 }
30 }, timeoutMs);
31 } else {
32 this.timeout = null;
33 }
34 }
35
36 resolve(value: T): void {
37 if (this.resolved) {
38 log.warn('Trying to resolve already resolved promise');
39 return;
40 }
41 this.markResolved();
42 this.resolveCallback(value);
43 }
44
45 reject(reason?: unknown): void {
46 if (this.resolved) {
47 log.warn('Trying to reject already resolved promise');
48 return;
49 }
50 this.markResolved();
51 this.rejectCallback(reason);
52 }
53
54 private markResolved() {
55 this.resolved = true;
56 if (this.timeout !== null) {
57 clearTimeout(this.timeout);
58 }
59 }
60}
diff --git a/language-web/src/main/js/utils/Timer.ts b/language-web/src/main/js/utils/Timer.ts
deleted file mode 100644
index 8f653070..00000000
--- a/language-web/src/main/js/utils/Timer.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1export class Timer {
2 readonly callback: () => void;
3
4 readonly defaultTimeout: number;
5
6 timeout: number | null = null;
7
8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => {
10 this.timeout = null;
11 callback();
12 };
13 this.defaultTimeout = defaultTimeout;
14 }
15
16 schedule(timeout: number | null = null): void {
17 if (this.timeout === null) {
18 this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout);
19 }
20 }
21
22 reschedule(timeout: number | null = null): void {
23 this.cancel();
24 this.schedule(timeout);
25 }
26
27 cancel(): void {
28 if (this.timeout !== null) {
29 clearTimeout(this.timeout);
30 this.timeout = null;
31 }
32 }
33}
diff --git a/language-web/src/main/js/utils/logger.ts b/language-web/src/main/js/utils/logger.ts
deleted file mode 100644
index 306d122c..00000000
--- a/language-web/src/main/js/utils/logger.ts
+++ /dev/null
@@ -1,49 +0,0 @@
1import styles, { CSPair } from 'ansi-styles';
2import log from 'loglevel';
3import * as prefix from 'loglevel-plugin-prefix';
4
5const colors: Partial<Record<string, CSPair>> = {
6 TRACE: styles.magenta,
7 DEBUG: styles.cyan,
8 INFO: styles.blue,
9 WARN: styles.yellow,
10 ERROR: styles.red,
11};
12
13prefix.reg(log);
14
15if (DEBUG) {
16 log.setLevel(log.levels.DEBUG);
17} else {
18 log.setLevel(log.levels.WARN);
19}
20
21if ('chrome' in window) {
22 // Only Chromium supports console ANSI escape sequences.
23 prefix.apply(log, {
24 format(level, name, timestamp) {
25 const formattedTimestamp = `${styles.gray.open}[${timestamp.toString()}]${styles.gray.close}`;
26 const levelColor = colors[level.toUpperCase()] || styles.red;
27 const formattedLevel = `${levelColor.open}${level}${levelColor.close}`;
28 const formattedName = `${styles.green.open}(${name || 'root'})${styles.green.close}`;
29 return `${formattedTimestamp} ${formattedLevel} ${formattedName}`;
30 },
31 });
32} else {
33 prefix.apply(log, {
34 template: '[%t] %l (%n)',
35 });
36}
37
38const appLogger = log.getLogger(PACKAGE_NAME);
39
40appLogger.info('Version:', PACKAGE_NAME, PACKAGE_VERSION);
41appLogger.info('Debug mode:', DEBUG);
42
43export function getLoggerFromRoot(name: string | symbol): log.Logger {
44 return log.getLogger(name);
45}
46
47export function getLogger(name: string | symbol): log.Logger {
48 return getLoggerFromRoot(`${PACKAGE_NAME}.${name.toString()}`);
49}
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts
deleted file mode 100644
index 8b872e06..00000000
--- a/language-web/src/main/js/xtext/ContentAssistService.ts
+++ /dev/null
@@ -1,219 +0,0 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import { syntaxTree } from '@codemirror/language';
7import type { Transaction } from '@codemirror/state';
8import escapeStringRegexp from 'escape-string-regexp';
9
10import { implicitCompletion } from '../language/props';
11import type { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import type { ContentAssistEntry } from './xtextServiceResults';
14
15const PROPOSALS_LIMIT = 1000;
16
17const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
18
19const HIGH_PRIORITY_KEYWORDS = ['<->', '~>'];
20
21const log = getLogger('xtext.ContentAssistService');
22
23interface IFoundToken {
24 from: number;
25
26 to: number;
27
28 implicitCompletion: boolean;
29
30 text: string;
31}
32
33function findToken({ pos, state }: CompletionContext): IFoundToken | null {
34 const token = syntaxTree(state).resolveInner(pos, -1);
35 if (token === null) {
36 return null;
37 }
38 if (token.firstChild !== null) {
39 // We only autocomplete terminal nodes. If the current node is nonterminal,
40 // returning `null` makes us autocomplete with the empty prefix instead.
41 return null;
42 }
43 return {
44 from: token.from,
45 to: token.to,
46 implicitCompletion: token.type.prop(implicitCompletion) || false,
47 text: state.sliceDoc(token.from, token.to),
48 };
49}
50
51function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean {
52 return token !== null
53 && token.implicitCompletion
54 && context.pos - token.from >= 2;
55}
56
57function computeSpan(prefix: string, entryCount: number): RegExp {
58 const escapedPrefix = escapeStringRegexp(prefix);
59 if (entryCount < PROPOSALS_LIMIT) {
60 // Proposals with the current prefix fit the proposals limit.
61 // We can filter client side as long as the current prefix is preserved.
62 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
63 }
64 // The current prefix overflows the proposals limits,
65 // so we have to fetch the completions again on the next keypress.
66 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
67 return new RegExp(`^${escapedPrefix}$`);
68}
69
70function createCompletion(entry: ContentAssistEntry): Completion {
71 let boost: number;
72 switch (entry.kind) {
73 case 'KEYWORD':
74 // Some hard-to-type operators should be on top.
75 boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99;
76 break;
77 case 'TEXT':
78 case 'SNIPPET':
79 boost = -90;
80 break;
81 default: {
82 // Penalize qualified names (vs available unqualified names).
83 const extraSegments = entry.proposal.match(/::/g)?.length || 0;
84 boost = Math.max(-5 * extraSegments, -50);
85 }
86 break;
87 }
88 return {
89 label: entry.proposal,
90 detail: entry.description,
91 info: entry.documentation,
92 type: entry.kind?.toLowerCase(),
93 boost,
94 };
95}
96
97export class ContentAssistService {
98 private readonly updateService: UpdateService;
99
100 private lastCompletion: CompletionResult | null = null;
101
102 constructor(updateService: UpdateService) {
103 this.updateService = updateService;
104 }
105
106 onTransaction(transaction: Transaction): void {
107 if (this.shouldInvalidateCachedCompletion(transaction)) {
108 this.lastCompletion = null;
109 }
110 }
111
112 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
113 const tokenBefore = findToken(context);
114 if (!context.explicit && !shouldCompleteImplicitly(tokenBefore, context)) {
115 return {
116 from: context.pos,
117 options: [],
118 };
119 }
120 let range: { from: number, to: number };
121 let prefix = '';
122 if (tokenBefore === null) {
123 range = {
124 from: context.pos,
125 to: context.pos,
126 };
127 prefix = '';
128 } else {
129 range = {
130 from: tokenBefore.from,
131 to: tokenBefore.to,
132 };
133 const prefixLength = context.pos - tokenBefore.from;
134 if (prefixLength > 0) {
135 prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from);
136 }
137 }
138 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) {
139 log.trace('Returning cached completion result');
140 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
141 return {
142 ...this.lastCompletion as CompletionResult,
143 ...range,
144 };
145 }
146 this.lastCompletion = null;
147 const entries = await this.updateService.fetchContentAssist({
148 resource: this.updateService.resourceName,
149 serviceType: 'assist',
150 caretOffset: context.pos,
151 proposalsLimit: PROPOSALS_LIMIT,
152 }, context);
153 if (context.aborted) {
154 return {
155 ...range,
156 options: [],
157 };
158 }
159 const options: Completion[] = [];
160 entries.forEach((entry) => {
161 if (prefix === entry.prefix) {
162 // Xtext will generate completions that do not complete the current token,
163 // e.g., `(` after trying to complete an indetifier,
164 // but we ignore those, since CodeMirror won't filter for them anyways.
165 options.push(createCompletion(entry));
166 }
167 });
168 log.debug('Fetched', options.length, 'completions from server');
169 this.lastCompletion = {
170 ...range,
171 options,
172 span: computeSpan(prefix, entries.length),
173 };
174 return this.lastCompletion;
175 }
176
177 private shouldReturnCachedCompletion(
178 token: { from: number, to: number, text: string } | null,
179 ): boolean {
180 if (token === null || this.lastCompletion === null) {
181 return false;
182 }
183 const { from, to, text } = token;
184 const { from: lastFrom, to: lastTo, span } = this.lastCompletion;
185 if (!lastTo) {
186 return true;
187 }
188 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
189 return from >= transformedFrom
190 && to <= transformedTo
191 && typeof span !== 'undefined'
192 && span.exec(text) !== null;
193 }
194
195 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
196 if (!transaction.docChanged || this.lastCompletion === null) {
197 return false;
198 }
199 const { from: lastFrom, to: lastTo } = this.lastCompletion;
200 if (!lastTo) {
201 return true;
202 }
203 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
204 let invalidate = false;
205 transaction.changes.iterChangedRanges((fromA, toA) => {
206 if (fromA < transformedFrom || toA > transformedTo) {
207 invalidate = true;
208 }
209 });
210 return invalidate;
211 }
212
213 private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] {
214 const changes = this.updateService.computeChangesSinceLastUpdate();
215 const transformedFrom = changes.mapPos(lastFrom);
216 const transformedTo = changes.mapPos(lastTo, 1);
217 return [transformedFrom, transformedTo];
218 }
219}
diff --git a/language-web/src/main/js/xtext/HighlightingService.ts b/language-web/src/main/js/xtext/HighlightingService.ts
deleted file mode 100644
index dfbb4a19..00000000
--- a/language-web/src/main/js/xtext/HighlightingService.ts
+++ /dev/null
@@ -1,37 +0,0 @@
1import type { EditorStore } from '../editor/EditorStore';
2import type { IHighlightRange } from '../editor/semanticHighlighting';
3import type { UpdateService } from './UpdateService';
4import { highlightingResult } from './xtextServiceResults';
5
6export class HighlightingService {
7 private readonly store: EditorStore;
8
9 private readonly updateService: UpdateService;
10
11 constructor(store: EditorStore, updateService: UpdateService) {
12 this.store = store;
13 this.updateService = updateService;
14 }
15
16 onPush(push: unknown): void {
17 const { regions } = highlightingResult.parse(push);
18 const allChanges = this.updateService.computeChangesSinceLastUpdate();
19 const ranges: IHighlightRange[] = [];
20 regions.forEach(({ offset, length, styleClasses }) => {
21 if (styleClasses.length === 0) {
22 return;
23 }
24 const from = allChanges.mapPos(offset);
25 const to = allChanges.mapPos(offset + length);
26 if (to <= from) {
27 return;
28 }
29 ranges.push({
30 from,
31 to,
32 classes: styleClasses,
33 });
34 });
35 this.store.updateSemanticHighlighting(ranges);
36 }
37}
diff --git a/language-web/src/main/js/xtext/OccurrencesService.ts b/language-web/src/main/js/xtext/OccurrencesService.ts
deleted file mode 100644
index bc865537..00000000
--- a/language-web/src/main/js/xtext/OccurrencesService.ts
+++ /dev/null
@@ -1,127 +0,0 @@
1import { Transaction } from '@codemirror/state';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences';
5import type { UpdateService } from './UpdateService';
6import { getLogger } from '../utils/logger';
7import { Timer } from '../utils/Timer';
8import { XtextWebSocketClient } from './XtextWebSocketClient';
9import {
10 isConflictResult,
11 occurrencesResult,
12 TextRegion,
13} from './xtextServiceResults';
14
15const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
16
17// Must clear occurrences asynchronously from `onTransaction`,
18// because we must not emit a conflicting transaction when handling the pending transaction.
19const CLEAR_OCCURRENCES_TIMEOUT_MS = 10;
20
21const log = getLogger('xtext.OccurrencesService');
22
23function transformOccurrences(regions: TextRegion[]): IOccurrence[] {
24 const occurrences: IOccurrence[] = [];
25 regions.forEach(({ offset, length }) => {
26 if (length > 0) {
27 occurrences.push({
28 from: offset,
29 to: offset + length,
30 });
31 }
32 });
33 return occurrences;
34}
35
36export class OccurrencesService {
37 private readonly store: EditorStore;
38
39 private readonly webSocketClient: XtextWebSocketClient;
40
41 private readonly updateService: UpdateService;
42
43 private hasOccurrences = false;
44
45 private readonly findOccurrencesTimer = new Timer(() => {
46 this.handleFindOccurrences();
47 }, FIND_OCCURRENCES_TIMEOUT_MS);
48
49 private readonly clearOccurrencesTimer = new Timer(() => {
50 this.clearOccurrences();
51 }, CLEAR_OCCURRENCES_TIMEOUT_MS);
52
53 constructor(
54 store: EditorStore,
55 webSocketClient: XtextWebSocketClient,
56 updateService: UpdateService,
57 ) {
58 this.store = store;
59 this.webSocketClient = webSocketClient;
60 this.updateService = updateService;
61 }
62
63 onTransaction(transaction: Transaction): void {
64 if (transaction.docChanged) {
65 this.clearOccurrencesTimer.schedule();
66 this.findOccurrencesTimer.reschedule();
67 }
68 if (transaction.isUserEvent('select')) {
69 this.findOccurrencesTimer.reschedule();
70 }
71 }
72
73 private handleFindOccurrences() {
74 this.clearOccurrencesTimer.cancel();
75 this.updateOccurrences().catch((error) => {
76 log.error('Unexpected error while updating occurrences', error);
77 this.clearOccurrences();
78 });
79 }
80
81 private async updateOccurrences() {
82 await this.updateService.update();
83 const result = await this.webSocketClient.send({
84 resource: this.updateService.resourceName,
85 serviceType: 'occurrences',
86 expectedStateId: this.updateService.xtextStateId,
87 caretOffset: this.store.state.selection.main.head,
88 });
89 const allChanges = this.updateService.computeChangesSinceLastUpdate();
90 if (!allChanges.empty || isConflictResult(result, 'canceled')) {
91 // Stale occurrences result, the user already made some changes.
92 // We can safely ignore the occurrences and schedule a new find occurrences call.
93 this.clearOccurrences();
94 this.findOccurrencesTimer.schedule();
95 return;
96 }
97 const parsedOccurrencesResult = occurrencesResult.safeParse(result);
98 if (!parsedOccurrencesResult.success) {
99 log.error(
100 'Unexpected occurences result',
101 result,
102 'not an OccurrencesResult: ',
103 parsedOccurrencesResult.error,
104 );
105 this.clearOccurrences();
106 return;
107 }
108 const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data;
109 if (stateId !== this.updateService.xtextStateId) {
110 log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId);
111 this.clearOccurrences();
112 return;
113 }
114 const write = transformOccurrences(writeRegions);
115 const read = transformOccurrences(readRegions);
116 this.hasOccurrences = write.length > 0 || read.length > 0;
117 log.debug('Found', write.length, 'write and', read.length, 'read occurrences');
118 this.store.updateOccurrences(write, read);
119 }
120
121 private clearOccurrences() {
122 if (this.hasOccurrences) {
123 this.store.updateOccurrences([], []);
124 this.hasOccurrences = false;
125 }
126 }
127}
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts
deleted file mode 100644
index e78944a9..00000000
--- a/language-web/src/main/js/xtext/UpdateService.ts
+++ /dev/null
@@ -1,363 +0,0 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 ChangeSpec,
5 StateEffect,
6 Transaction,
7} from '@codemirror/state';
8import { nanoid } from 'nanoid';
9
10import type { EditorStore } from '../editor/EditorStore';
11import type { XtextWebSocketClient } from './XtextWebSocketClient';
12import { ConditionVariable } from '../utils/ConditionVariable';
13import { getLogger } from '../utils/logger';
14import { Timer } from '../utils/Timer';
15import {
16 ContentAssistEntry,
17 contentAssistResult,
18 documentStateResult,
19 formattingResult,
20 isConflictResult,
21} from './xtextServiceResults';
22
23const UPDATE_TIMEOUT_MS = 500;
24
25const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
26
27const log = getLogger('xtext.UpdateService');
28
29const setDirtyChanges = StateEffect.define<ChangeSet>();
30
31export interface IAbortSignal {
32 aborted: boolean;
33}
34
35export class UpdateService {
36 resourceName: string;
37
38 xtextStateId: string | null = null;
39
40 private readonly store: EditorStore;
41
42 /**
43 * The changes being synchronized to the server if a full or delta text update is running,
44 * `null` otherwise.
45 */
46 private pendingUpdate: ChangeSet | null = null;
47
48 /**
49 * Local changes not yet sychronized to the server and not part of the running update, if any.
50 */
51 private dirtyChanges: ChangeSet;
52
53 private readonly webSocketClient: XtextWebSocketClient;
54
55 private readonly updatedCondition = new ConditionVariable(
56 () => this.pendingUpdate === null && this.xtextStateId !== null,
57 WAIT_FOR_UPDATE_TIMEOUT_MS,
58 );
59
60 private readonly idleUpdateTimer = new Timer(() => {
61 this.handleIdleUpdate();
62 }, UPDATE_TIMEOUT_MS);
63
64 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
65 this.resourceName = `${nanoid(7)}.problem`;
66 this.store = store;
67 this.dirtyChanges = this.newEmptyChangeSet();
68 this.webSocketClient = webSocketClient;
69 }
70
71 onReconnect(): void {
72 this.xtextStateId = null;
73 this.updateFullText().catch((error) => {
74 log.error('Unexpected error during initial update', error);
75 });
76 }
77
78 onTransaction(transaction: Transaction): void {
79 const setDirtyChangesEffect = transaction.effects.find(
80 (effect) => effect.is(setDirtyChanges),
81 ) as StateEffect<ChangeSet> | undefined;
82 if (setDirtyChangesEffect) {
83 const { value } = setDirtyChangesEffect;
84 if (this.pendingUpdate !== null) {
85 this.pendingUpdate = ChangeSet.empty(value.length);
86 }
87 this.dirtyChanges = value;
88 return;
89 }
90 if (transaction.docChanged) {
91 this.dirtyChanges = this.dirtyChanges.compose(transaction.changes);
92 this.idleUpdateTimer.reschedule();
93 }
94 }
95
96 /**
97 * Computes the summary of any changes happened since the last complete update.
98 *
99 * The result reflects any changes that happened since the `xtextStateId`
100 * version was uploaded to the server.
101 *
102 * @return the summary of changes since the last update
103 */
104 computeChangesSinceLastUpdate(): ChangeDesc {
105 return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc;
106 }
107
108 private handleIdleUpdate() {
109 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
110 return;
111 }
112 if (this.pendingUpdate === null) {
113 this.update().catch((error) => {
114 log.error('Unexpected error during scheduled update', error);
115 });
116 }
117 this.idleUpdateTimer.reschedule();
118 }
119
120 private newEmptyChangeSet() {
121 return ChangeSet.of([], this.store.state.doc.length);
122 }
123
124 async updateFullText(): Promise<void> {
125 await this.withUpdate(() => this.doUpdateFullText());
126 }
127
128 private async doUpdateFullText(): Promise<[string, void]> {
129 const result = await this.webSocketClient.send({
130 resource: this.resourceName,
131 serviceType: 'update',
132 fullText: this.store.state.doc.sliceString(0),
133 });
134 const { stateId } = documentStateResult.parse(result);
135 return [stateId, undefined];
136 }
137
138 /**
139 * Makes sure that the document state on the server reflects recent
140 * local changes.
141 *
142 * Performs either an update with delta text or a full text update if needed.
143 * If there are not local dirty changes, the promise resolves immediately.
144 *
145 * @return a promise resolving when the update is completed
146 */
147 async update(): Promise<void> {
148 await this.prepareForDeltaUpdate();
149 const delta = this.computeDelta();
150 if (delta === null) {
151 return;
152 }
153 log.trace('Editor delta', delta);
154 await this.withUpdate(async () => {
155 const result = await this.webSocketClient.send({
156 resource: this.resourceName,
157 serviceType: 'update',
158 requiredStateId: this.xtextStateId,
159 ...delta,
160 });
161 const parsedDocumentStateResult = documentStateResult.safeParse(result);
162 if (parsedDocumentStateResult.success) {
163 return [parsedDocumentStateResult.data.stateId, undefined];
164 }
165 if (isConflictResult(result, 'invalidStateId')) {
166 return this.doFallbackToUpdateFullText();
167 }
168 throw parsedDocumentStateResult.error;
169 });
170 }
171
172 private doFallbackToUpdateFullText() {
173 if (this.pendingUpdate === null) {
174 throw new Error('Only a pending update can be extended');
175 }
176 log.warn('Delta update failed, performing full text update');
177 this.xtextStateId = null;
178 this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges);
179 this.dirtyChanges = this.newEmptyChangeSet();
180 return this.doUpdateFullText();
181 }
182
183 async fetchContentAssist(
184 params: Record<string, unknown>,
185 signal: IAbortSignal,
186 ): Promise<ContentAssistEntry[]> {
187 await this.prepareForDeltaUpdate();
188 if (signal.aborted) {
189 return [];
190 }
191 const delta = this.computeDelta();
192 if (delta !== null) {
193 log.trace('Editor delta', delta);
194 const entries = await this.withUpdate(async () => {
195 const result = await this.webSocketClient.send({
196 ...params,
197 requiredStateId: this.xtextStateId,
198 ...delta,
199 });
200 const parsedContentAssistResult = contentAssistResult.safeParse(result);
201 if (parsedContentAssistResult.success) {
202 const { stateId, entries: resultEntries } = parsedContentAssistResult.data;
203 return [stateId, resultEntries];
204 }
205 if (isConflictResult(result, 'invalidStateId')) {
206 log.warn('Server state invalid during content assist');
207 const [newStateId] = await this.doFallbackToUpdateFullText();
208 // We must finish this state update transaction to prepare for any push events
209 // before querying for content assist, so we just return `null` and will query
210 // the content assist service later.
211 return [newStateId, null];
212 }
213 throw parsedContentAssistResult.error;
214 });
215 if (entries !== null) {
216 return entries;
217 }
218 if (signal.aborted) {
219 return [];
220 }
221 }
222 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
223 return this.doFetchContentAssist(params, this.xtextStateId as string);
224 }
225
226 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) {
227 const result = await this.webSocketClient.send({
228 ...params,
229 requiredStateId: expectedStateId,
230 });
231 const { stateId, entries } = contentAssistResult.parse(result);
232 if (stateId !== expectedStateId) {
233 throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`);
234 }
235 return entries;
236 }
237
238 async formatText(): Promise<void> {
239 await this.update();
240 let { from, to } = this.store.state.selection.main;
241 if (to <= from) {
242 from = 0;
243 to = this.store.state.doc.length;
244 }
245 log.debug('Formatting from', from, 'to', to);
246 await this.withUpdate(async () => {
247 const result = await this.webSocketClient.send({
248 resource: this.resourceName,
249 serviceType: 'format',
250 selectionStart: from,
251 selectionEnd: to,
252 });
253 const { stateId, formattedText } = formattingResult.parse(result);
254 this.applyBeforeDirtyChanges({
255 from,
256 to,
257 insert: formattedText,
258 });
259 return [stateId, null];
260 });
261 }
262
263 private computeDelta() {
264 if (this.dirtyChanges.empty) {
265 return null;
266 }
267 let minFromA = Number.MAX_SAFE_INTEGER;
268 let maxToA = 0;
269 let minFromB = Number.MAX_SAFE_INTEGER;
270 let maxToB = 0;
271 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
272 minFromA = Math.min(minFromA, fromA);
273 maxToA = Math.max(maxToA, toA);
274 minFromB = Math.min(minFromB, fromB);
275 maxToB = Math.max(maxToB, toB);
276 });
277 return {
278 deltaOffset: minFromA,
279 deltaReplaceLength: maxToA - minFromA,
280 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
281 };
282 }
283
284 private applyBeforeDirtyChanges(changeSpec: ChangeSpec) {
285 const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges;
286 const revertChanges = pendingChanges.invert(this.store.state.doc);
287 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength);
288 const redoChanges = pendingChanges.map(applyBefore.desc);
289 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
290 this.store.dispatch({
291 changes: changeSet,
292 effects: [
293 setDirtyChanges.of(redoChanges),
294 ],
295 });
296 }
297
298 /**
299 * Executes an asynchronous callback that updates the state on the server.
300 *
301 * Ensures that updates happen sequentially and manages `pendingUpdate`
302 * and `dirtyChanges` to reflect changes being synchronized to the server
303 * and not yet synchronized to the server, respectively.
304 *
305 * Optionally, `callback` may return a second value that is retured by this function.
306 *
307 * Once the remote procedure call to update the server state finishes
308 * and returns the new `stateId`, `callback` must return _immediately_
309 * to ensure that the local `stateId` is updated likewise to be able to handle
310 * push messages referring to the new `stateId` from the server.
311 * If additional work is needed to compute the second value in some cases,
312 * use `T | null` instead of `T` as a return type and signal the need for additional
313 * computations by returning `null`. Thus additional computations can be performed
314 * outside of the critical section.
315 *
316 * @param callback the asynchronous callback that updates the server state
317 * @return a promise resolving to the second value returned by `callback`
318 */
319 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
320 if (this.pendingUpdate !== null) {
321 throw new Error('Another update is pending, will not perform update');
322 }
323 this.pendingUpdate = this.dirtyChanges;
324 this.dirtyChanges = this.newEmptyChangeSet();
325 let newStateId: string | null = null;
326 try {
327 let result: T;
328 [newStateId, result] = await callback();
329 this.xtextStateId = newStateId;
330 this.pendingUpdate = null;
331 this.updatedCondition.notifyAll();
332 return result;
333 } catch (e) {
334 log.error('Error while update', e);
335 if (this.pendingUpdate === null) {
336 log.error('pendingUpdate was cleared during update');
337 } else {
338 this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges);
339 }
340 this.pendingUpdate = null;
341 this.webSocketClient.forceReconnectOnError();
342 this.updatedCondition.rejectAll(e);
343 throw e;
344 }
345 }
346
347 /**
348 * Ensures that there is some state available on the server (`xtextStateId`)
349 * and that there is not pending update.
350 *
351 * After this function resolves, a delta text update is possible.
352 *
353 * @return a promise resolving when there is a valid state id but no pending update
354 */
355 private async prepareForDeltaUpdate() {
356 // If no update is pending, but the full text hasn't been uploaded to the server yet,
357 // we must start a full text upload.
358 if (this.pendingUpdate === null && this.xtextStateId === null) {
359 await this.updateFullText();
360 }
361 await this.updatedCondition.waitFor();
362 }
363}
diff --git a/language-web/src/main/js/xtext/ValidationService.ts b/language-web/src/main/js/xtext/ValidationService.ts
deleted file mode 100644
index ff7d3700..00000000
--- a/language-web/src/main/js/xtext/ValidationService.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import type { Diagnostic } from '@codemirror/lint';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { UpdateService } from './UpdateService';
5import { validationResult } from './xtextServiceResults';
6
7export class ValidationService {
8 private readonly store: EditorStore;
9
10 private readonly updateService: UpdateService;
11
12 constructor(store: EditorStore, updateService: UpdateService) {
13 this.store = store;
14 this.updateService = updateService;
15 }
16
17 onPush(push: unknown): void {
18 const { issues } = validationResult.parse(push);
19 const allChanges = this.updateService.computeChangesSinceLastUpdate();
20 const diagnostics: Diagnostic[] = [];
21 issues.forEach(({
22 offset,
23 length,
24 severity,
25 description,
26 }) => {
27 if (severity === 'ignore') {
28 return;
29 }
30 diagnostics.push({
31 from: allChanges.mapPos(offset),
32 to: allChanges.mapPos(offset + length),
33 severity,
34 message: description,
35 });
36 });
37 this.store.updateDiagnostics(diagnostics);
38 }
39}
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts
deleted file mode 100644
index 0898e725..00000000
--- a/language-web/src/main/js/xtext/XtextClient.ts
+++ /dev/null
@@ -1,86 +0,0 @@
1import type {
2 CompletionContext,
3 CompletionResult,
4} from '@codemirror/autocomplete';
5import type { Transaction } from '@codemirror/state';
6
7import type { EditorStore } from '../editor/EditorStore';
8import { ContentAssistService } from './ContentAssistService';
9import { HighlightingService } from './HighlightingService';
10import { OccurrencesService } from './OccurrencesService';
11import { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import { ValidationService } from './ValidationService';
14import { XtextWebSocketClient } from './XtextWebSocketClient';
15import { XtextWebPushService } from './xtextMessages';
16
17const log = getLogger('xtext.XtextClient');
18
19export class XtextClient {
20 private readonly webSocketClient: XtextWebSocketClient;
21
22 private readonly updateService: UpdateService;
23
24 private readonly contentAssistService: ContentAssistService;
25
26 private readonly highlightingService: HighlightingService;
27
28 private readonly validationService: ValidationService;
29
30 private readonly occurrencesService: OccurrencesService;
31
32 constructor(store: EditorStore) {
33 this.webSocketClient = new XtextWebSocketClient(
34 () => this.updateService.onReconnect(),
35 (resource, stateId, service, push) => this.onPush(resource, stateId, service, push),
36 );
37 this.updateService = new UpdateService(store, this.webSocketClient);
38 this.contentAssistService = new ContentAssistService(this.updateService);
39 this.highlightingService = new HighlightingService(store, this.updateService);
40 this.validationService = new ValidationService(store, this.updateService);
41 this.occurrencesService = new OccurrencesService(
42 store,
43 this.webSocketClient,
44 this.updateService,
45 );
46 }
47
48 onTransaction(transaction: Transaction): void {
49 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc
50 // _before_ the current edit, so we call it before `updateService`.
51 this.contentAssistService.onTransaction(transaction);
52 this.updateService.onTransaction(transaction);
53 this.occurrencesService.onTransaction(transaction);
54 }
55
56 private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) {
57 const { resourceName, xtextStateId } = this.updateService;
58 if (resource !== resourceName) {
59 log.error('Unknown resource name: expected:', resourceName, 'got:', resource);
60 return;
61 }
62 if (stateId !== xtextStateId) {
63 log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId);
64 // The current push message might be stale (referring to a previous state),
65 // so this is not neccessarily an error and there is no need to force-reconnect.
66 return;
67 }
68 switch (service) {
69 case 'highlight':
70 this.highlightingService.onPush(push);
71 return;
72 case 'validate':
73 this.validationService.onPush(push);
74 }
75 }
76
77 contentAssist(context: CompletionContext): Promise<CompletionResult> {
78 return this.contentAssistService.contentAssist(context);
79 }
80
81 formatText(): void {
82 this.updateService.formatText().catch((e) => {
83 log.error('Error while formatting text', e);
84 });
85 }
86}
diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
deleted file mode 100644
index 2ce20a54..00000000
--- a/language-web/src/main/js/xtext/XtextWebSocketClient.ts
+++ /dev/null
@@ -1,362 +0,0 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 xtextWebErrorResponse,
8 XtextWebRequest,
9 xtextWebOkResponse,
10 xtextWebPushMessage,
11 XtextWebPushService,
12} from './xtextMessages';
13import { pongResult } from './xtextServiceResults';
14
15const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
16
17const WEBSOCKET_CLOSE_OK = 1000;
18
19const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
20
21const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
22
23const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
24
25const PING_TIMEOUT_MS = 10 * 1000;
26
27const REQUEST_TIMEOUT_MS = 1000;
28
29const log = getLogger('xtext.XtextWebSocketClient');
30
31export type ReconnectHandler = () => void;
32
33export type PushHandler = (
34 resourceId: string,
35 stateId: string,
36 service: XtextWebPushService,
37 data: unknown,
38) => void;
39
40enum State {
41 Initial,
42 Opening,
43 TabVisible,
44 TabHiddenIdle,
45 TabHiddenWaiting,
46 Error,
47 TimedOut,
48}
49
50export class XtextWebSocketClient {
51 private nextMessageId = 0;
52
53 private connection!: WebSocket;
54
55 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
56
57 private readonly onReconnect: ReconnectHandler;
58
59 private readonly onPush: PushHandler;
60
61 private state = State.Initial;
62
63 private reconnectTryCount = 0;
64
65 private readonly idleTimer = new Timer(() => {
66 this.handleIdleTimeout();
67 }, BACKGROUND_IDLE_TIMEOUT_MS);
68
69 private readonly pingTimer = new Timer(() => {
70 this.sendPing();
71 }, PING_TIMEOUT_MS);
72
73 private readonly reconnectTimer = new Timer(() => {
74 this.handleReconnect();
75 });
76
77 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
78 this.onReconnect = onReconnect;
79 this.onPush = onPush;
80 document.addEventListener('visibilitychange', () => {
81 this.handleVisibilityChange();
82 });
83 this.reconnect();
84 }
85
86 private get isLogicallyClosed(): boolean {
87 return this.state === State.Error || this.state === State.TimedOut;
88 }
89
90 get isOpen(): boolean {
91 return this.state === State.TabVisible
92 || this.state === State.TabHiddenIdle
93 || this.state === State.TabHiddenWaiting;
94 }
95
96 private reconnect() {
97 if (this.isOpen || this.state === State.Opening) {
98 log.error('Trying to reconnect from', this.state);
99 return;
100 }
101 this.state = State.Opening;
102 const webSocketServer = window.origin.replace(/^http/, 'ws');
103 const webSocketUrl = `${webSocketServer}/xtext-service`;
104 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
105 this.connection.addEventListener('open', () => {
106 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
107 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
108 this.forceReconnectOnError();
109 }
110 if (document.visibilityState === 'hidden') {
111 this.handleTabHidden();
112 } else {
113 this.handleTabVisibleConnected();
114 }
115 log.info('Connected to websocket');
116 this.nextMessageId = 0;
117 this.reconnectTryCount = 0;
118 this.pingTimer.schedule();
119 this.onReconnect();
120 });
121 this.connection.addEventListener('error', (event) => {
122 log.error('Unexpected websocket error', event);
123 this.forceReconnectOnError();
124 });
125 this.connection.addEventListener('message', (event) => {
126 this.handleMessage(event.data);
127 });
128 this.connection.addEventListener('close', (event) => {
129 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
130 && this.pendingRequests.size === 0) {
131 log.info('Websocket closed');
132 return;
133 }
134 log.error('Websocket closed unexpectedly', event.code, event.reason);
135 this.forceReconnectOnError();
136 });
137 }
138
139 private handleVisibilityChange() {
140 if (document.visibilityState === 'hidden') {
141 if (this.state === State.TabVisible) {
142 this.handleTabHidden();
143 }
144 return;
145 }
146 this.idleTimer.cancel();
147 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
148 this.handleTabVisibleConnected();
149 return;
150 }
151 if (this.state === State.TimedOut) {
152 this.reconnect();
153 }
154 }
155
156 private handleTabHidden() {
157 log.debug('Tab hidden while websocket is connected');
158 this.state = State.TabHiddenIdle;
159 this.idleTimer.schedule();
160 }
161
162 private handleTabVisibleConnected() {
163 log.debug('Tab visible while websocket is connected');
164 this.state = State.TabVisible;
165 }
166
167 private handleIdleTimeout() {
168 log.trace('Waiting for pending tasks before disconnect');
169 if (this.state === State.TabHiddenIdle) {
170 this.state = State.TabHiddenWaiting;
171 this.handleWaitingForDisconnect();
172 }
173 }
174
175 private handleWaitingForDisconnect() {
176 if (this.state !== State.TabHiddenWaiting) {
177 return;
178 }
179 const pending = this.pendingRequests.size;
180 if (pending === 0) {
181 log.info('Closing idle websocket');
182 this.state = State.TimedOut;
183 this.closeConnection(1000, 'idle timeout');
184 return;
185 }
186 log.info('Waiting for', pending, 'pending requests before closing websocket');
187 }
188
189 private sendPing() {
190 if (!this.isOpen) {
191 return;
192 }
193 const ping = nanoid();
194 log.trace('Ping', ping);
195 this.send({ ping }).then((result) => {
196 const parsedPongResult = pongResult.safeParse(result);
197 if (parsedPongResult.success && parsedPongResult.data.pong === ping) {
198 log.trace('Pong', ping);
199 this.pingTimer.schedule();
200 } else {
201 log.error('Invalid pong:', parsedPongResult, 'expected:', ping);
202 this.forceReconnectOnError();
203 }
204 }).catch((error) => {
205 log.error('Error while waiting for ping', error);
206 this.forceReconnectOnError();
207 });
208 }
209
210 send(request: unknown): Promise<unknown> {
211 if (!this.isOpen) {
212 throw new Error('Not open');
213 }
214 const messageId = this.nextMessageId.toString(16);
215 if (messageId in this.pendingRequests) {
216 log.error('Message id wraparound still pending', messageId);
217 this.rejectRequest(messageId, new Error('Message id wraparound'));
218 }
219 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
220 this.nextMessageId = 0;
221 } else {
222 this.nextMessageId += 1;
223 }
224 const message = JSON.stringify({
225 id: messageId,
226 request,
227 } as XtextWebRequest);
228 log.trace('Sending message', message);
229 return new Promise((resolve, reject) => {
230 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
231 this.removePendingRequest(messageId);
232 });
233 this.pendingRequests.set(messageId, task);
234 this.connection.send(message);
235 });
236 }
237
238 private handleMessage(messageStr: unknown) {
239 if (typeof messageStr !== 'string') {
240 log.error('Unexpected binary message', messageStr);
241 this.forceReconnectOnError();
242 return;
243 }
244 log.trace('Incoming websocket message', messageStr);
245 let message: unknown;
246 try {
247 message = JSON.parse(messageStr);
248 } catch (error) {
249 log.error('Json parse error', error);
250 this.forceReconnectOnError();
251 return;
252 }
253 const okResponse = xtextWebOkResponse.safeParse(message);
254 if (okResponse.success) {
255 const { id, response } = okResponse.data;
256 this.resolveRequest(id, response);
257 return;
258 }
259 const errorResponse = xtextWebErrorResponse.safeParse(message);
260 if (errorResponse.success) {
261 const { id, error, message: errorMessage } = errorResponse.data;
262 this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`));
263 if (error === 'server') {
264 log.error('Reconnecting due to server error: ', errorMessage);
265 this.forceReconnectOnError();
266 }
267 return;
268 }
269 const pushMessage = xtextWebPushMessage.safeParse(message);
270 if (pushMessage.success) {
271 const {
272 resource,
273 stateId,
274 service,
275 push,
276 } = pushMessage.data;
277 this.onPush(resource, stateId, service, push);
278 } else {
279 log.error(
280 'Unexpected websocket message:',
281 message,
282 'not ok response because:',
283 okResponse.error,
284 'not error response because:',
285 errorResponse.error,
286 'not push message because:',
287 pushMessage.error,
288 );
289 this.forceReconnectOnError();
290 }
291 }
292
293 private resolveRequest(messageId: string, value: unknown) {
294 const pendingRequest = this.pendingRequests.get(messageId);
295 if (pendingRequest) {
296 pendingRequest.resolve(value);
297 this.removePendingRequest(messageId);
298 return;
299 }
300 log.error('Trying to resolve unknown request', messageId, 'with', value);
301 }
302
303 private rejectRequest(messageId: string, reason?: unknown) {
304 const pendingRequest = this.pendingRequests.get(messageId);
305 if (pendingRequest) {
306 pendingRequest.reject(reason);
307 this.removePendingRequest(messageId);
308 return;
309 }
310 log.error('Trying to reject unknown request', messageId, 'with', reason);
311 }
312
313 private removePendingRequest(messageId: string) {
314 this.pendingRequests.delete(messageId);
315 this.handleWaitingForDisconnect();
316 }
317
318 forceReconnectOnError(): void {
319 if (this.isLogicallyClosed) {
320 return;
321 }
322 this.abortPendingRequests();
323 this.closeConnection(1000, 'reconnecting due to error');
324 log.error('Reconnecting after delay due to error');
325 this.handleErrorState();
326 }
327
328 private abortPendingRequests() {
329 this.pendingRequests.forEach((request) => {
330 request.reject(new Error('Websocket disconnect'));
331 });
332 this.pendingRequests.clear();
333 }
334
335 private closeConnection(code: number, reason: string) {
336 this.pingTimer.cancel();
337 const { readyState } = this.connection;
338 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
339 this.connection.close(code, reason);
340 }
341 }
342
343 private handleErrorState() {
344 this.state = State.Error;
345 this.reconnectTryCount += 1;
346 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
347 log.info('Reconnecting in', delay, 'ms');
348 this.reconnectTimer.schedule(delay);
349 }
350
351 private handleReconnect() {
352 if (this.state !== State.Error) {
353 log.error('Unexpected reconnect in', this.state);
354 return;
355 }
356 if (document.visibilityState === 'hidden') {
357 this.state = State.TimedOut;
358 } else {
359 this.reconnect();
360 }
361 }
362}
diff --git a/language-web/src/main/js/xtext/xtextMessages.ts b/language-web/src/main/js/xtext/xtextMessages.ts
deleted file mode 100644
index c4305fcf..00000000
--- a/language-web/src/main/js/xtext/xtextMessages.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1import { z } from 'zod';
2
3export const xtextWebRequest = z.object({
4 id: z.string().nonempty(),
5 request: z.unknown(),
6});
7
8export type XtextWebRequest = z.infer<typeof xtextWebRequest>;
9
10export const xtextWebOkResponse = z.object({
11 id: z.string().nonempty(),
12 response: z.unknown(),
13});
14
15export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>;
16
17export const xtextWebErrorKind = z.enum(['request', 'server']);
18
19export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>;
20
21export const xtextWebErrorResponse = z.object({
22 id: z.string().nonempty(),
23 error: xtextWebErrorKind,
24 message: z.string(),
25});
26
27export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>;
28
29export const xtextWebPushService = z.enum(['highlight', 'validate']);
30
31export type XtextWebPushService = z.infer<typeof xtextWebPushService>;
32
33export const xtextWebPushMessage = z.object({
34 resource: z.string().nonempty(),
35 stateId: z.string().nonempty(),
36 service: xtextWebPushService,
37 push: z.unknown(),
38});
39
40export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>;
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts
deleted file mode 100644
index f79b059c..00000000
--- a/language-web/src/main/js/xtext/xtextServiceResults.ts
+++ /dev/null
@@ -1,112 +0,0 @@
1import { z } from 'zod';
2
3export const pongResult = z.object({
4 pong: z.string().nonempty(),
5});
6
7export type PongResult = z.infer<typeof pongResult>;
8
9export const documentStateResult = z.object({
10 stateId: z.string().nonempty(),
11});
12
13export type DocumentStateResult = z.infer<typeof documentStateResult>;
14
15export const conflict = z.enum(['invalidStateId', 'canceled']);
16
17export type Conflict = z.infer<typeof conflict>;
18
19export const serviceConflictResult = z.object({
20 conflict,
21});
22
23export type ServiceConflictResult = z.infer<typeof serviceConflictResult>;
24
25export function isConflictResult(result: unknown, conflictType: Conflict): boolean {
26 const parsedConflictResult = serviceConflictResult.safeParse(result);
27 return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType;
28}
29
30export const severity = z.enum(['error', 'warning', 'info', 'ignore']);
31
32export type Severity = z.infer<typeof severity>;
33
34export const issue = z.object({
35 description: z.string().nonempty(),
36 severity,
37 line: z.number().int(),
38 column: z.number().int().nonnegative(),
39 offset: z.number().int().nonnegative(),
40 length: z.number().int().nonnegative(),
41});
42
43export type Issue = z.infer<typeof issue>;
44
45export const validationResult = z.object({
46 issues: issue.array(),
47});
48
49export type ValidationResult = z.infer<typeof validationResult>;
50
51export const replaceRegion = z.object({
52 offset: z.number().int().nonnegative(),
53 length: z.number().int().nonnegative(),
54 text: z.string(),
55});
56
57export type ReplaceRegion = z.infer<typeof replaceRegion>;
58
59export const textRegion = z.object({
60 offset: z.number().int().nonnegative(),
61 length: z.number().int().nonnegative(),
62});
63
64export type TextRegion = z.infer<typeof textRegion>;
65
66export const contentAssistEntry = z.object({
67 prefix: z.string(),
68 proposal: z.string().nonempty(),
69 label: z.string().optional(),
70 description: z.string().nonempty().optional(),
71 documentation: z.string().nonempty().optional(),
72 escapePosition: z.number().int().nonnegative().optional(),
73 textReplacements: replaceRegion.array(),
74 editPositions: textRegion.array(),
75 kind: z.string().nonempty(),
76});
77
78export type ContentAssistEntry = z.infer<typeof contentAssistEntry>;
79
80export const contentAssistResult = documentStateResult.extend({
81 entries: contentAssistEntry.array(),
82});
83
84export type ContentAssistResult = z.infer<typeof contentAssistResult>;
85
86export const highlightingRegion = z.object({
87 offset: z.number().int().nonnegative(),
88 length: z.number().int().nonnegative(),
89 styleClasses: z.string().nonempty().array(),
90});
91
92export type HighlightingRegion = z.infer<typeof highlightingRegion>;
93
94export const highlightingResult = z.object({
95 regions: highlightingRegion.array(),
96});
97
98export type HighlightingResult = z.infer<typeof highlightingResult>;
99
100export const occurrencesResult = documentStateResult.extend({
101 writeRegions: textRegion.array(),
102 readRegions: textRegion.array(),
103});
104
105export type OccurrencesResult = z.infer<typeof occurrencesResult>;
106
107export const formattingResult = documentStateResult.extend({
108 formattedText: z.string(),
109 replaceRegion: textRegion,
110});
111
112export type FormattingResult = z.infer<typeof formattingResult>;
diff --git a/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java b/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java
deleted file mode 100644
index a26ce040..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java
+++ /dev/null
@@ -1,204 +0,0 @@
1package tools.refinery.language.web;
2
3import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasSize;
6import static org.hamcrest.Matchers.instanceOf;
7import static org.hamcrest.Matchers.startsWith;
8import static org.junit.jupiter.api.Assertions.assertThrows;
9
10import java.io.IOException;
11import java.net.InetSocketAddress;
12import java.net.URI;
13import java.util.concurrent.CompletableFuture;
14import java.util.concurrent.CompletionException;
15
16import org.eclipse.jetty.http.HttpHeader;
17import org.eclipse.jetty.http.HttpStatus;
18import org.eclipse.jetty.server.Server;
19import org.eclipse.jetty.servlet.ServletContextHandler;
20import org.eclipse.jetty.servlet.ServletHolder;
21import org.eclipse.jetty.websocket.api.Session;
22import org.eclipse.jetty.websocket.api.StatusCode;
23import org.eclipse.jetty.websocket.api.annotations.WebSocket;
24import org.eclipse.jetty.websocket.api.exceptions.UpgradeException;
25import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
26import org.eclipse.jetty.websocket.client.WebSocketClient;
27import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
28import org.eclipse.xtext.testing.GlobalRegistries;
29import org.eclipse.xtext.testing.GlobalRegistries.GlobalStateMemento;
30import org.junit.jupiter.api.AfterEach;
31import org.junit.jupiter.api.BeforeEach;
32import org.junit.jupiter.api.Test;
33import org.junit.jupiter.params.ParameterizedTest;
34import org.junit.jupiter.params.provider.ValueSource;
35
36import tools.refinery.language.web.tests.WebSocketIntegrationTestClient;
37import tools.refinery.language.web.xtext.servlet.XtextStatusCode;
38import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
39
40class ProblemWebSocketServletIntegrationTest {
41 private static int SERVER_PORT = 28080;
42
43 private static String SERVLET_URI = "/xtext-service";
44
45 private GlobalStateMemento stateBeforeInjectorCreation;
46
47 private Server server;
48
49 private WebSocketClient client;
50
51 @BeforeEach
52 void beforeEach() throws Exception {
53 stateBeforeInjectorCreation = GlobalRegistries.makeCopyOfGlobalState();
54 client = new WebSocketClient();
55 client.start();
56 }
57
58 @AfterEach
59 void afterEach() throws Exception {
60 client.stop();
61 client = null;
62 if (server != null) {
63 server.stop();
64 server = null;
65 }
66 stateBeforeInjectorCreation.restoreGlobalState();
67 stateBeforeInjectorCreation = null;
68 }
69
70 @Test
71 void updateTest() {
72 startServer(null);
73 var clientSocket = new UpdateTestClient();
74 var session = connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
75 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(),
76 equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
77 clientSocket.waitForTestResult();
78 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
79 var responses = clientSocket.getResponses();
80 assertThat(responses, hasSize(5));
81 assertThat(responses.get(0), equalTo("{\"id\":\"foo\",\"response\":{\"stateId\":\"-80000000\"}}"));
82 assertThat(responses.get(1), startsWith(
83 "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"highlight\",\"push\":{\"regions\":["));
84 assertThat(responses.get(2), equalTo(
85 "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"validate\",\"push\":{\"issues\":[]}}"));
86 assertThat(responses.get(3), equalTo("{\"id\":\"bar\",\"response\":{\"stateId\":\"-7fffffff\"}}"));
87 assertThat(responses.get(4), startsWith(
88 "{\"resource\":\"test.problem\",\"stateId\":\"-7fffffff\",\"service\":\"highlight\",\"push\":{\"regions\":["));
89 }
90
91 @WebSocket
92 public static class UpdateTestClient extends WebSocketIntegrationTestClient {
93 @Override
94 protected void arrange(Session session, int responsesReceived) throws IOException {
95 switch (responsesReceived) {
96 case 0 -> session.getRemote().sendString(
97 "{\"id\":\"foo\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"fullText\":\"class Person.\n\"}}");
98 case 3 -> session.getRemote().sendString(
99 "{\"id\":\"bar\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"requiredStateId\":\"-80000000\",\"deltaText\":\"indiv q.\nnode(q).\n\",\"deltaOffset\":\"0\",\"deltaReplaceLength\":\"0\"}}");
100 case 5 -> session.close();
101 }
102 }
103 }
104
105 @Test
106 void badSubProtocolTest() {
107 startServer(null);
108 var clientSocket = new CloseImmediatelyTestClient();
109 var session = connect(clientSocket, null, "<invalid sub-protocol>");
110 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(), equalTo(null));
111 clientSocket.waitForTestResult();
112 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
113 }
114
115 @WebSocket
116 public static class CloseImmediatelyTestClient extends WebSocketIntegrationTestClient {
117 @Override
118 protected void arrange(Session session, int responsesReceived) throws IOException {
119 session.close();
120 }
121 }
122
123 @Test
124 void subProtocolNegotiationTest() {
125 startServer(null);
126 var clientSocket = new CloseImmediatelyTestClient();
127 var session = connect(clientSocket, null, "<invalid sub-protocol>", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
128 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(),
129 equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
130 clientSocket.waitForTestResult();
131 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
132 }
133
134 @Test
135 void invalidJsonTest() {
136 startServer(null);
137 var clientSocket = new InvalidJsonTestClient();
138 connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
139 clientSocket.waitForTestResult();
140 assertThat(clientSocket.getCloseStatusCode(), equalTo(XtextStatusCode.INVALID_JSON));
141 }
142
143 @WebSocket
144 public static class InvalidJsonTestClient extends WebSocketIntegrationTestClient {
145 @Override
146 protected void arrange(Session session, int responsesReceived) throws IOException {
147 session.getRemote().sendString("<invalid json>");
148 }
149 }
150
151 @ParameterizedTest(name = "Origin: {0}")
152 @ValueSource(strings = { "https://refinery.example", "https://refinery.example:443", "HTTPS://REFINERY.EXAMPLE" })
153 void validOriginTest(String origin) {
154 startServer("https://refinery.example;https://refinery.example:443");
155 var clientSocket = new CloseImmediatelyTestClient();
156 connect(clientSocket, origin, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
157 clientSocket.waitForTestResult();
158 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
159 }
160
161 @Test
162 void invalidOriginTest() {
163 startServer("https://refinery.example;https://refinery.example:443");
164 var clientSocket = new CloseImmediatelyTestClient();
165 var exception = assertThrows(CompletionException.class,
166 () -> connect(clientSocket, "https://invalid.example", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
167 var innerException = exception.getCause();
168 assertThat(innerException, instanceOf(UpgradeException.class));
169 assertThat(((UpgradeException) innerException).getResponseStatusCode(), equalTo(HttpStatus.FORBIDDEN_403));
170 }
171
172 private void startServer(String allowedOrigins) {
173 server = new Server(new InetSocketAddress(SERVER_PORT));
174 var handler = new ServletContextHandler();
175 var holder = new ServletHolder(ProblemWebSocketServlet.class);
176 if (allowedOrigins != null) {
177 holder.setInitParameter(ProblemWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, allowedOrigins);
178 }
179 handler.addServlet(holder, SERVLET_URI);
180 JettyWebSocketServletContainerInitializer.configure(handler, null);
181 server.setHandler(handler);
182 try {
183 server.start();
184 } catch (Exception e) {
185 throw new RuntimeException("Failed to start websocket server");
186 }
187 }
188
189 private Session connect(Object webSocketClient, String origin, String... subProtocols) {
190 var upgradeRequest = new ClientUpgradeRequest();
191 if (origin != null) {
192 upgradeRequest.setHeader(HttpHeader.ORIGIN.name(), origin);
193 }
194 upgradeRequest.setSubProtocols(subProtocols);
195 CompletableFuture<Session> sessionFuture;
196 try {
197 sessionFuture = client.connect(webSocketClient, URI.create("ws://localhost:" + SERVER_PORT + SERVLET_URI),
198 upgradeRequest);
199 } catch (IOException e) {
200 throw new AssertionError("Unexpected exception while connection to websocket", e);
201 }
202 return sessionFuture.join();
203 }
204}
diff --git a/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java b/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java
deleted file mode 100644
index b70d0ed5..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java
+++ /dev/null
@@ -1,42 +0,0 @@
1package tools.refinery.language.web.tests;
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.concurrent.ExecutorService;
6
7import org.eclipse.xtext.ide.ExecutorServiceProvider;
8
9import com.google.inject.Singleton;
10
11@Singleton
12public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider {
13 private List<RestartableCachedThreadPool> servicesToShutDown = new ArrayList<>();
14
15 @Override
16 protected ExecutorService createInstance(String key) {
17 var instance = new RestartableCachedThreadPool();
18 synchronized (servicesToShutDown) {
19 servicesToShutDown.add(instance);
20 }
21 return instance;
22 }
23
24 public void waitForAllTasksToFinish() {
25 synchronized (servicesToShutDown) {
26 for (var executorService : servicesToShutDown) {
27 executorService.waitForAllTasksToFinish();
28 }
29 }
30 }
31
32 @Override
33 public void dispose() {
34 super.dispose();
35 synchronized (servicesToShutDown) {
36 for (var executorService : servicesToShutDown) {
37 executorService.waitForTermination();
38 }
39 servicesToShutDown.clear();
40 }
41 }
42}
diff --git a/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java b/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java
deleted file mode 100644
index 43c12faa..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java
+++ /dev/null
@@ -1,47 +0,0 @@
1package tools.refinery.language.web.tests;
2
3import org.eclipse.xtext.ide.ExecutorServiceProvider;
4import org.eclipse.xtext.util.DisposableRegistry;
5import org.eclipse.xtext.util.Modules2;
6
7import com.google.inject.Guice;
8import com.google.inject.Injector;
9
10import tools.refinery.language.ide.ProblemIdeModule;
11import tools.refinery.language.tests.ProblemInjectorProvider;
12import tools.refinery.language.web.ProblemWebModule;
13import tools.refinery.language.web.ProblemWebSetup;
14
15public class ProblemWebInjectorProvider extends ProblemInjectorProvider {
16
17 protected Injector internalCreateInjector() {
18 return new ProblemWebSetup() {
19 @Override
20 public Injector createInjector() {
21 return Guice.createInjector(
22 Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule()));
23 }
24 }.createInjectorAndDoEMFRegistration();
25 }
26
27 protected ProblemWebModule createWebModule() {
28 // Await termination of the executor service to avoid race conditions between
29 // the tasks in the service and the {@link
30 // org.eclipse.xtext.testing.extensions.InjectionExtension}.
31 return new ProblemWebModule() {
32 @SuppressWarnings("unused")
33 public Class<? extends ExecutorServiceProvider> bindExecutorServiceProvider() {
34 return AwaitTerminationExecutorServiceProvider.class;
35 }
36 };
37 }
38
39 @Override
40 public void restoreRegistry() {
41 // Also make sure to dispose any IDisposable instances (that may depend on the
42 // global state) created by Xtext before restoring the global state.
43 var disposableRegistry = getInjector().getInstance(DisposableRegistry.class);
44 disposableRegistry.dispose();
45 super.restoreRegistry();
46 }
47}
diff --git a/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java b/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java
deleted file mode 100644
index 1468273d..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java
+++ /dev/null
@@ -1,109 +0,0 @@
1package tools.refinery.language.web.tests;
2
3import java.util.Collection;
4import java.util.List;
5import java.util.concurrent.Callable;
6import java.util.concurrent.ExecutionException;
7import java.util.concurrent.ExecutorService;
8import java.util.concurrent.Executors;
9import java.util.concurrent.Future;
10import java.util.concurrent.TimeUnit;
11import java.util.concurrent.TimeoutException;
12
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16public class RestartableCachedThreadPool implements ExecutorService {
17 private static final Logger LOG = LoggerFactory.getLogger(RestartableCachedThreadPool.class);
18
19 private ExecutorService delegate;
20
21 public RestartableCachedThreadPool() {
22 delegate = createExecutorService();
23 }
24
25 public void waitForAllTasksToFinish() {
26 delegate.shutdown();
27 waitForTermination();
28 delegate = createExecutorService();
29 }
30
31 public void waitForTermination() {
32 try {
33 delegate.awaitTermination(1, TimeUnit.SECONDS);
34 } catch (InterruptedException e) {
35 LOG.warn("Interrupted while waiting for delegate executor to stop", e);
36 }
37 }
38
39 protected ExecutorService createExecutorService() {
40 return Executors.newCachedThreadPool();
41 }
42
43 @Override
44 public boolean awaitTermination(long arg0, TimeUnit arg1) throws InterruptedException {
45 return delegate.awaitTermination(arg0, arg1);
46 }
47
48 @Override
49 public void execute(Runnable arg0) {
50 delegate.execute(arg0);
51 }
52
53 @Override
54 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
55 throws InterruptedException {
56 return delegate.invokeAll(arg0, arg1, arg2);
57 }
58
59 @Override
60 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0) throws InterruptedException {
61 return delegate.invokeAll(arg0);
62 }
63
64 @Override
65 public <T> T invokeAny(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
66 throws InterruptedException, ExecutionException, TimeoutException {
67 return delegate.invokeAny(arg0, arg1, arg2);
68 }
69
70 @Override
71 public <T> T invokeAny(Collection<? extends Callable<T>> arg0) throws InterruptedException, ExecutionException {
72 return delegate.invokeAny(arg0);
73 }
74
75 @Override
76 public boolean isShutdown() {
77 return delegate.isShutdown();
78 }
79
80 @Override
81 public boolean isTerminated() {
82 return delegate.isTerminated();
83 }
84
85 @Override
86 public void shutdown() {
87 delegate.shutdown();
88 }
89
90 @Override
91 public List<Runnable> shutdownNow() {
92 return delegate.shutdownNow();
93 }
94
95 @Override
96 public <T> Future<T> submit(Callable<T> arg0) {
97 return delegate.submit(arg0);
98 }
99
100 @Override
101 public <T> Future<T> submit(Runnable arg0, T arg1) {
102 return delegate.submit(arg0, arg1);
103 }
104
105 @Override
106 public Future<?> submit(Runnable arg0) {
107 return delegate.submit(arg0);
108 }
109}
diff --git a/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java b/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java
deleted file mode 100644
index 49464d27..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java
+++ /dev/null
@@ -1,98 +0,0 @@
1package tools.refinery.language.web.tests;
2
3import static org.junit.jupiter.api.Assertions.fail;
4
5import java.io.IOException;
6import java.time.Duration;
7import java.util.ArrayList;
8import java.util.List;
9
10import org.eclipse.jetty.websocket.api.Session;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
13import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
14import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
15
16public abstract class WebSocketIntegrationTestClient {
17 private static long TIMEOUT_MILLIS = Duration.ofSeconds(1).toMillis();
18
19 private boolean finished = false;
20
21 private Object lock = new Object();
22
23 private Throwable error;
24
25 private int closeStatusCode;
26
27 private List<String> responses = new ArrayList<>();
28
29 public int getCloseStatusCode() {
30 return closeStatusCode;
31 }
32
33 public List<String> getResponses() {
34 return responses;
35 }
36
37 @OnWebSocketConnect
38 public void onConnect(Session session) {
39 arrangeAndCatchErrors(session);
40 }
41
42 private void arrangeAndCatchErrors(Session session) {
43 try {
44 arrange(session, responses.size());
45 } catch (Exception e) {
46 finishedWithError(e);
47 }
48 }
49
50 protected abstract void arrange(Session session, int responsesReceived) throws IOException;
51
52 @OnWebSocketClose
53 public void onClose(int statusCode, String reason) {
54 closeStatusCode = statusCode;
55 testFinished();
56 }
57
58 @OnWebSocketError
59 public void onError(Throwable error) {
60 finishedWithError(error);
61 }
62
63 @OnWebSocketMessage
64 public void onMessage(Session session, String message) {
65 responses.add(message);
66 arrangeAndCatchErrors(session);
67 }
68
69 private void finishedWithError(Throwable t) {
70 error = t;
71 testFinished();
72 }
73
74 private void testFinished() {
75 synchronized (lock) {
76 finished = true;
77 lock.notify();
78 }
79 }
80
81 public void waitForTestResult() {
82 synchronized (lock) {
83 if (!finished) {
84 try {
85 lock.wait(TIMEOUT_MILLIS);
86 } catch (InterruptedException e) {
87 fail("Unexpected InterruptedException", e);
88 }
89 }
90 }
91 if (!finished) {
92 fail("Test still not finished after timeout");
93 }
94 if (error != null) {
95 fail("Unexpected exception in websocket thread", error);
96 }
97 }
98}
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
deleted file mode 100644
index 5b8fedba..00000000
--- a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
+++ /dev/null
@@ -1,165 +0,0 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasProperty;
6import static org.hamcrest.Matchers.instanceOf;
7import static org.mockito.Mockito.mock;
8import static org.mockito.Mockito.times;
9import static org.mockito.Mockito.verify;
10
11import java.util.List;
12import java.util.Map;
13
14import org.eclipse.emf.common.util.URI;
15import org.eclipse.xtext.resource.IResourceServiceProvider;
16import org.eclipse.xtext.testing.InjectWith;
17import org.eclipse.xtext.testing.extensions.InjectionExtension;
18import org.eclipse.xtext.web.server.model.DocumentStateResult;
19import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult;
20import org.eclipse.xtext.web.server.validation.ValidationResult;
21import org.junit.jupiter.api.BeforeEach;
22import org.junit.jupiter.api.Test;
23import org.junit.jupiter.api.extension.ExtendWith;
24import org.mockito.ArgumentCaptor;
25import org.mockito.junit.jupiter.MockitoExtension;
26
27import com.google.inject.Inject;
28
29import tools.refinery.language.web.tests.AwaitTerminationExecutorServiceProvider;
30import tools.refinery.language.web.tests.ProblemWebInjectorProvider;
31import tools.refinery.language.web.xtext.server.ResponseHandler;
32import tools.refinery.language.web.xtext.server.ResponseHandlerException;
33import tools.refinery.language.web.xtext.server.TransactionExecutor;
34import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
35import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
36import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
37
38@ExtendWith(MockitoExtension.class)
39@ExtendWith(InjectionExtension.class)
40@InjectWith(ProblemWebInjectorProvider.class)
41class TransactionExecutorTest {
42 private static final String RESOURCE_NAME = "test.problem";
43
44 private static final String PROBLEM_CONTENT_TYPE = "application/x-tools.refinery.problem";
45
46 private static final String TEST_PROBLEM = """
47 class Person {
48 Person[0..*] friend opposite friend
49 }
50
51 friend(a, b).
52 """;
53
54 private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("resource", RESOURCE_NAME, "serviceType",
55 "update", "fullText", TEST_PROBLEM);
56
57 @Inject
58 private IResourceServiceProvider.Registry resourceServiceProviderRegistry;
59
60 @Inject
61 private AwaitTerminationExecutorServiceProvider executorServices;
62
63 private TransactionExecutor transactionExecutor;
64
65 @BeforeEach
66 void beforeEach() {
67 transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry);
68 }
69
70 @Test
71 void updateFullTextTest() throws ResponseHandlerException {
72 var captor = newCaptor();
73 var stateId = updateFullText(captor);
74 assertThatPrecomputedMessagesAreReceived(stateId, captor.getAllValues());
75 }
76
77 @Test
78 void updateDeltaTextHighlightAndValidationChange() throws ResponseHandlerException {
79 var stateId = updateFullText();
80 var responseHandler = sendRequestAndWaitForAllResponses(
81 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
82 stateId, "deltaText", "individual q.\nnode(q).\n<invalid text>\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
83
84 var captor = newCaptor();
85 verify(responseHandler, times(3)).onResponse(captor.capture());
86 var newStateId = getStateId("bar", captor.getAllValues().get(0));
87 assertThatPrecomputedMessagesAreReceived(newStateId, captor.getAllValues());
88 }
89
90 @Test
91 void updateDeltaTextHighlightChangeOnly() throws ResponseHandlerException {
92 var stateId = updateFullText();
93 var responseHandler = sendRequestAndWaitForAllResponses(
94 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
95 stateId, "deltaText", "indiv q.\nnode(q).\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
96
97 var captor = newCaptor();
98 verify(responseHandler, times(2)).onResponse(captor.capture());
99 var newStateId = getStateId("bar", captor.getAllValues().get(0));
100 assertHighlightingResponse(newStateId, captor.getAllValues().get(1));
101 }
102
103 @Test
104 void fullTextWithoutResourceTest() throws ResponseHandlerException {
105 var resourceServiceProvider = resourceServiceProviderRegistry
106 .getResourceServiceProvider(URI.createFileURI(RESOURCE_NAME));
107 resourceServiceProviderRegistry.getContentTypeToFactoryMap().put(PROBLEM_CONTENT_TYPE, resourceServiceProvider);
108 var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo",
109 Map.of("contentType", PROBLEM_CONTENT_TYPE, "fullText", TEST_PROBLEM, "serviceType", "validate")));
110
111 var captor = newCaptor();
112 verify(responseHandler).onResponse(captor.capture());
113 var response = captor.getValue();
114 assertThat(response, hasProperty("id", equalTo("foo")));
115 assertThat(response, hasProperty("responseData", instanceOf(ValidationResult.class)));
116 }
117
118 private ArgumentCaptor<XtextWebResponse> newCaptor() {
119 return ArgumentCaptor.forClass(XtextWebResponse.class);
120 }
121
122 private String updateFullText() throws ResponseHandlerException {
123 return updateFullText(newCaptor());
124 }
125
126 private String updateFullText(ArgumentCaptor<XtextWebResponse> captor) throws ResponseHandlerException {
127 var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", UPDATE_FULL_TEXT_PARAMS));
128
129 verify(responseHandler, times(3)).onResponse(captor.capture());
130 return getStateId("foo", captor.getAllValues().get(0));
131 }
132
133 private ResponseHandler sendRequestAndWaitForAllResponses(XtextWebRequest request) throws ResponseHandlerException {
134 var responseHandler = mock(ResponseHandler.class);
135 transactionExecutor.setResponseHandler(responseHandler);
136 transactionExecutor.handleRequest(request);
137 executorServices.waitForAllTasksToFinish();
138 return responseHandler;
139 }
140
141 private String getStateId(String requestId, XtextWebResponse okResponse) {
142 assertThat(okResponse, hasProperty("id", equalTo(requestId)));
143 assertThat(okResponse, hasProperty("responseData", instanceOf(DocumentStateResult.class)));
144 return ((DocumentStateResult) ((XtextWebOkResponse) okResponse).getResponseData()).getStateId();
145 }
146
147 private void assertThatPrecomputedMessagesAreReceived(String stateId, List<XtextWebResponse> responses) {
148 assertHighlightingResponse(stateId, responses.get(1));
149 assertValidationResponse(stateId, responses.get(2));
150 }
151
152 private void assertHighlightingResponse(String stateId, XtextWebResponse highlightingResponse) {
153 assertThat(highlightingResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
154 assertThat(highlightingResponse, hasProperty("stateId", equalTo(stateId)));
155 assertThat(highlightingResponse, hasProperty("service", equalTo("highlight")));
156 assertThat(highlightingResponse, hasProperty("pushData", instanceOf(HighlightingResult.class)));
157 }
158
159 private void assertValidationResponse(String stateId, XtextWebResponse validationResponse) {
160 assertThat(validationResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
161 assertThat(validationResponse, hasProperty("stateId", equalTo(stateId)));
162 assertThat(validationResponse, hasProperty("service", equalTo("validate")));
163 assertThat(validationResponse, hasProperty("pushData", instanceOf(ValidationResult.class)));
164 }
165}
diff --git a/language-web/tsconfig.json b/language-web/tsconfig.json
deleted file mode 100644
index cb5f6b13..00000000
--- a/language-web/tsconfig.json
+++ /dev/null
@@ -1,18 +0,0 @@
1{
2 "compilerOptions": {
3 "target": "es2020",
4 "module": "esnext",
5 "moduleResolution": "node",
6 "esModuleInterop": true,
7 "allowSyntheticDefaultImports": true,
8 "jsx": "react",
9 "strict": true,
10 "noImplicitOverride": true,
11 "noImplicitReturns": true,
12 "exactOptionalPropertyTypes": false,
13 "noEmit": true,
14 "skipLibCheck": true
15 },
16 "include": ["./src/main/js/**/*"],
17 "exclude": ["./build/generated/sources/lezer/*"]
18}
diff --git a/language-web/tsconfig.sonar.json b/language-web/tsconfig.sonar.json
deleted file mode 100644
index 54eef68b..00000000
--- a/language-web/tsconfig.sonar.json
+++ /dev/null
@@ -1,17 +0,0 @@
1{
2 "compilerOptions": {
3 "target": "es2020",
4 "module": "esnext",
5 "moduleResolution": "node",
6 "esModuleInterop": true,
7 "allowSyntheticDefaultImports": true,
8 "jsx": "react",
9 "strict": true,
10 "noImplicitOverride": true,
11 "noImplicitReturns": true,
12 "noEmit": true,
13 "skipLibCheck": true
14 },
15 "include": ["./src/main/js/**/*"],
16 "exclude": ["./src/main/js/xtext/**/*"]
17}
diff --git a/language-web/webpack.config.js b/language-web/webpack.config.js
deleted file mode 100644
index 801a705c..00000000
--- a/language-web/webpack.config.js
+++ /dev/null
@@ -1,232 +0,0 @@
1const fs = require('fs');
2const path = require('path');
3
4const { DefinePlugin } = require('webpack');
5const HtmlWebpackPlugin = require('html-webpack-plugin');
6const HtmlWebpackInjectPreload = require('@principalstudio/html-webpack-inject-preload');
7const MiniCssExtractPlugin = require('mini-css-extract-plugin');
8const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
9
10const packageInfo = require('./package.json');
11
12const currentNodeEnv = process.env.NODE_ENV || 'development';
13const devMode = currentNodeEnv !== 'production';
14const outputPath = path.resolve(__dirname, 'build/webpack', currentNodeEnv);
15
16const portNumberOrElse = (envName, fallback) => {
17 const value = process.env[envName];
18 return value ? parseInt(value) : fallback;
19};
20const listenHost = process.env['LISTEN_HOST'] || 'localhost';
21const listenPort = portNumberOrElse('LISTEN_PORT', 1313);
22const apiHost = process.env['API_HOST'] || listenHost;
23const apiPort = portNumberOrElse('API_PORT', 1312);
24const publicHost = process.env['PUBLIC_HOST'] || listenHost;
25const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort);
26
27const resolveSources = sources => path.resolve(__dirname, 'src', sources);
28const mainJsSources = resolveSources('main/js');
29const babelLoaderFilters = {
30 include: [mainJsSources],
31};
32const babelPresets = [
33 [
34 '@babel/preset-env',
35 {
36 targets: 'defaults',
37 },
38 ],
39 '@babel/preset-react',
40];
41const babelPlugins = [
42 '@babel/plugin-transform-runtime',
43]
44const magicCommentsLoader = {
45 loader: 'magic-comments-loader',
46 options: {
47 webpackChunkName: true,
48 }
49};
50
51module.exports = {
52 mode: devMode ? 'development' : 'production',
53 entry: './src/main/js',
54 output: {
55 path: outputPath,
56 publicPath: '/',
57 filename: devMode ? '[name].js' : '[name].[contenthash].js',
58 chunkFilename: devMode ? '[name].js' : '[name].[contenthash].js',
59 assetModuleFilename: devMode ? '[name].js' : '[name].[contenthash][ext]',
60 clean: true,
61 crossOriginLoading: 'anonymous',
62 },
63 module: {
64 rules: [
65 {
66 test: /\.jsx?$/i,
67 ...babelLoaderFilters,
68 use: [
69 {
70 loader: 'babel-loader',
71 options: {
72 presets: babelPresets,
73 plugins: [
74 [
75 '@babel/plugin-proposal-class-properties',
76 {
77 loose: false,
78 },
79 ...babelPlugins,
80 ],
81 ],
82 assumptions: {
83 'setPublicClassFields': false,
84 },
85 },
86 },
87 magicCommentsLoader,
88 ],
89 },
90 {
91 test: /.tsx?$/i,
92 ...babelLoaderFilters,
93 use: [
94 {
95 loader: 'babel-loader',
96 options: {
97 presets: [
98 ...babelPresets,
99 [
100 '@babel/preset-typescript',
101 {
102 isTSX: true,
103 allExtensions: true,
104 allowDeclareFields: true,
105 onlyRemoveTypeImports: true,
106 optimizeConstEnums: true,
107 },
108 ]
109 ],
110 plugins: babelPlugins,
111 },
112 },
113 magicCommentsLoader,
114 ],
115 },
116 {
117 test: /\.scss$/i,
118 use: [
119 devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
120 'css-loader',
121 {
122 loader: 'sass-loader',
123 options: {
124 implementation: require.resolve('sass'),
125 },
126 },
127 ],
128 },
129 {
130 test: /\.(gif|png|jpe?g|svg?)$/i,
131 use: [
132 {
133 loader: 'image-webpack-loader',
134 options: {
135 disable: true,
136 }
137 },
138 ],
139 type: 'asset',
140 },
141 {
142 test: /\.woff2?$/i,
143 type: 'asset/resource',
144 },
145 ],
146 },
147 resolve: {
148 modules: [
149 'node_modules',
150 mainJsSources,
151 ],
152 extensions: ['.js', '.jsx', '.ts', '.tsx'],
153 },
154 devtool: devMode ? 'inline-source-map' : 'source-map',
155 optimization: {
156 providedExports: !devMode,
157 sideEffects: devMode ? 'flag' : true,
158 splitChunks: {
159 chunks: 'all',
160 cacheGroups: {
161 defaultVendors: {
162 test: /[\\/]node_modules[\\/]/,
163 priority: -10,
164 reuseExistingChunk: true,
165 filename: devMode ? 'vendor.[id].js' : 'vendor.[contenthash].js',
166 },
167 default: {
168 minChunks: 2,
169 priority: -20,
170 reuseExistingChunk: true,
171 },
172 },
173 },
174 },
175 devServer: {
176 client: {
177 logging: 'info',
178 overlay: true,
179 progress: true,
180 webSocketURL: {
181 hostname: publicHost,
182 port: publicPort,
183 protocol: publicPort === 443 ? 'wss' : 'ws',
184 },
185 },
186 compress: true,
187 host: listenHost,
188 port: listenPort,
189 proxy: {
190 '/xtext-service': {
191 target: `${apiPort === 443 ? 'https' : 'http'}://${apiHost}:${apiPort}`,
192 ws: true,
193 },
194 },
195 },
196 plugins: [
197 new DefinePlugin({
198 'DEBUG': JSON.stringify(devMode),
199 'PACKAGE_NAME': JSON.stringify(packageInfo.name),
200 'PACKAGE_VERSION': JSON.stringify(packageInfo.version),
201 }),
202 new MiniCssExtractPlugin({
203 filename: '[name].[contenthash].css',
204 chunkFilename: '[name].[contenthash].css',
205 }),
206 new SubresourceIntegrityPlugin(),
207 new HtmlWebpackPlugin({
208 template: 'src/main/html/index.html',
209 minify: devMode ? false : {
210 collapseWhitespace: true,
211 removeComments: true,
212 removeOptionalTags: true,
213 removeRedundantAttributes: true,
214 removeScriptTypeAttributes: true,
215 removeStyleLinkTypeAttributes: true,
216 useShortDoctype: true,
217 },
218 }),
219 new HtmlWebpackInjectPreload({
220 files: [
221 {
222 match: /(roboto-latin-(400|500)-normal|jetbrains-mono-latin-variable).*\.woff2/,
223 attributes: {
224 as: 'font',
225 type: 'font/woff2',
226 crossorigin: 'anonymous',
227 },
228 },
229 ],
230 }),
231 ],
232};