diff options
Diffstat (limited to 'subprojects/language-web')
78 files changed, 5992 insertions, 0 deletions
diff --git a/subprojects/language-web/.editorconfig b/subprojects/language-web/.editorconfig new file mode 100644 index 00000000..1b78e967 --- /dev/null +++ b/subprojects/language-web/.editorconfig | |||
@@ -0,0 +1,5 @@ | |||
1 | [src/main/css/xtext/**.css] | ||
2 | indent_style = tab | ||
3 | |||
4 | [src/main/js/xtext/**.js] | ||
5 | indent_style = tab | ||
diff --git a/subprojects/language-web/.eslintrc.js b/subprojects/language-web/.eslintrc.js new file mode 100644 index 00000000..b27feb0e --- /dev/null +++ b/subprojects/language-web/.eslintrc.js | |||
@@ -0,0 +1,40 @@ | |||
1 | // Loosely based on | ||
2 | // https://github.com/iamturns/create-exposed-app/blob/f14e435b8ce179c89cce3eea89e56202153a53da/.eslintrc.js | ||
3 | module.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/subprojects/language-web/.stylelintrc.js b/subprojects/language-web/.stylelintrc.js new file mode 100644 index 00000000..7adf8f26 --- /dev/null +++ b/subprojects/language-web/.stylelintrc.js | |||
@@ -0,0 +1,15 @@ | |||
1 | module.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/subprojects/language-web/build.gradle b/subprojects/language-web/build.gradle new file mode 100644 index 00000000..a549288a --- /dev/null +++ b/subprojects/language-web/build.gradle | |||
@@ -0,0 +1,147 @@ | |||
1 | plugins { | ||
2 | id 'refinery-frontend-workspace' | ||
3 | id 'refinery-java-application' | ||
4 | id 'refinery-xtext-conventions' | ||
5 | } | ||
6 | |||
7 | import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn | ||
8 | |||
9 | dependencies { | ||
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 | |||
23 | def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage') | ||
24 | |||
25 | for (taskName in ['compileJava', 'processResources']) { | ||
26 | tasks.named(taskName) { | ||
27 | dependsOn generateXtextLanguage | ||
28 | } | ||
29 | } | ||
30 | |||
31 | def webpackOutputDir = "${buildDir}/webpack" | ||
32 | def productionResources = "${webpackOutputDir}/production" | ||
33 | def serverMainClass = 'tools.refinery.language.web.ServerLauncher' | ||
34 | |||
35 | frontend { | ||
36 | assembleScript = 'assemble:webpack' | ||
37 | } | ||
38 | |||
39 | def installFrontend = tasks.named('installFrontend') | ||
40 | |||
41 | def 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 | |||
50 | def assembleFrontend = tasks.named('assembleFrontend') | ||
51 | assembleFrontend.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 | |||
62 | def 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 | |||
76 | def 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 | |||
90 | tasks.named('check') { | ||
91 | dependsOn(eslint, stylelint) | ||
92 | } | ||
93 | |||
94 | mainClassName = serverMainClass | ||
95 | |||
96 | tasks.named('jar') { | ||
97 | dependsOn assembleFrontend | ||
98 | from(productionResources) { | ||
99 | into 'webapp' | ||
100 | } | ||
101 | } | ||
102 | |||
103 | tasks.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 | |||
116 | def 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 | |||
127 | tasks.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 | |||
136 | sonarqube.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/subprojects/language-web/package.json b/subprojects/language-web/package.json new file mode 100644 index 00000000..5fa977d9 --- /dev/null +++ b/subprojects/language-web/package.json | |||
@@ -0,0 +1,103 @@ | |||
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/subprojects/language-web/src/main/css/index.scss b/subprojects/language-web/src/main/css/index.scss new file mode 100644 index 00000000..ad876aaf --- /dev/null +++ b/subprojects/language-web/src/main/css/index.scss | |||
@@ -0,0 +1,16 @@ | |||
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/subprojects/language-web/src/main/css/themeVariables.module.scss b/subprojects/language-web/src/main/css/themeVariables.module.scss new file mode 100644 index 00000000..85af4219 --- /dev/null +++ b/subprojects/language-web/src/main/css/themeVariables.module.scss | |||
@@ -0,0 +1,9 @@ | |||
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/subprojects/language-web/src/main/css/themes.scss b/subprojects/language-web/src/main/css/themes.scss new file mode 100644 index 00000000..a30f1de3 --- /dev/null +++ b/subprojects/language-web/src/main/css/themes.scss | |||
@@ -0,0 +1,38 @@ | |||
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/subprojects/language-web/src/main/html/index.html b/subprojects/language-web/src/main/html/index.html new file mode 100644 index 00000000..f404aa8a --- /dev/null +++ b/subprojects/language-web/src/main/html/index.html | |||
@@ -0,0 +1,16 @@ | |||
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/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java new file mode 100644 index 00000000..b13ae95d --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java | |||
@@ -0,0 +1,52 @@ | |||
1 | package tools.refinery.language.web; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.time.Duration; | ||
5 | import java.util.regex.Pattern; | ||
6 | |||
7 | import org.eclipse.jetty.http.HttpHeader; | ||
8 | |||
9 | import jakarta.servlet.Filter; | ||
10 | import jakarta.servlet.FilterChain; | ||
11 | import jakarta.servlet.FilterConfig; | ||
12 | import jakarta.servlet.ServletException; | ||
13 | import jakarta.servlet.ServletRequest; | ||
14 | import jakarta.servlet.ServletResponse; | ||
15 | import jakarta.servlet.http.HttpServletRequest; | ||
16 | import jakarta.servlet.http.HttpServletResponse; | ||
17 | |||
18 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java new file mode 100644 index 00000000..ec55036f --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java | |||
@@ -0,0 +1,35 @@ | |||
1 | /* | ||
2 | * generated by Xtext 2.25.0 | ||
3 | */ | ||
4 | package tools.refinery.language.web; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
7 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
8 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
9 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
10 | |||
11 | import tools.refinery.language.web.occurrences.ProblemOccurrencesService; | ||
12 | import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher; | ||
13 | import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess; | ||
14 | import 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 | */ | ||
19 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java new file mode 100644 index 00000000..4738bc80 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java | |||
@@ -0,0 +1,25 @@ | |||
1 | /* | ||
2 | * generated by Xtext 2.25.0 | ||
3 | */ | ||
4 | package tools.refinery.language.web; | ||
5 | |||
6 | import org.eclipse.xtext.util.Modules2; | ||
7 | |||
8 | import com.google.inject.Guice; | ||
9 | import com.google.inject.Injector; | ||
10 | |||
11 | import tools.refinery.language.ProblemRuntimeModule; | ||
12 | import tools.refinery.language.ProblemStandaloneSetup; | ||
13 | import tools.refinery.language.ide.ProblemIdeModule; | ||
14 | |||
15 | /** | ||
16 | * Initialization support for running Xtext languages in web applications. | ||
17 | */ | ||
18 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java new file mode 100644 index 00000000..df67b521 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java | |||
@@ -0,0 +1,29 @@ | |||
1 | package tools.refinery.language.web; | ||
2 | |||
3 | import org.eclipse.xtext.util.DisposableRegistry; | ||
4 | |||
5 | import jakarta.servlet.ServletException; | ||
6 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; | ||
7 | |||
8 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java new file mode 100644 index 00000000..ffd903d0 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java | |||
@@ -0,0 +1,192 @@ | |||
1 | /* | ||
2 | * generated by Xtext 2.25.0 | ||
3 | */ | ||
4 | package tools.refinery.language.web; | ||
5 | |||
6 | import java.io.File; | ||
7 | import java.io.IOException; | ||
8 | import java.net.InetSocketAddress; | ||
9 | import java.net.URI; | ||
10 | import java.net.URISyntaxException; | ||
11 | import java.util.EnumSet; | ||
12 | import java.util.Optional; | ||
13 | import java.util.Set; | ||
14 | |||
15 | import org.eclipse.jetty.server.Server; | ||
16 | import org.eclipse.jetty.server.session.SessionHandler; | ||
17 | import org.eclipse.jetty.servlet.DefaultServlet; | ||
18 | import org.eclipse.jetty.servlet.ServletContextHandler; | ||
19 | import org.eclipse.jetty.servlet.ServletHolder; | ||
20 | import org.eclipse.jetty.util.resource.Resource; | ||
21 | import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; | ||
22 | import org.slf4j.Logger; | ||
23 | import org.slf4j.LoggerFactory; | ||
24 | |||
25 | import jakarta.servlet.DispatcherType; | ||
26 | import jakarta.servlet.SessionTrackingMode; | ||
27 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; | ||
28 | |||
29 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java new file mode 100644 index 00000000..d32bbb54 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java | |||
@@ -0,0 +1,16 @@ | |||
1 | package tools.refinery.language.web.occurrences; | ||
2 | |||
3 | import org.eclipse.emf.ecore.EObject; | ||
4 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
5 | |||
6 | import com.google.inject.Singleton; | ||
7 | |||
8 | import tools.refinery.language.model.problem.NamedElement; | ||
9 | |||
10 | @Singleton | ||
11 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java new file mode 100644 index 00000000..fe510f51 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java | |||
@@ -0,0 +1,44 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | |||
7 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java new file mode 100644 index 00000000..2a85afe3 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java | |||
@@ -0,0 +1,8 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
4 | |||
5 | @FunctionalInterface | ||
6 | public interface ResponseHandler { | ||
7 | void onResponse(XtextWebResponse response) throws ResponseHandlerException; | ||
8 | } | ||
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java new file mode 100644 index 00000000..34fcb546 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java | |||
@@ -0,0 +1,14 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java new file mode 100644 index 00000000..78e00a9e --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java | |||
@@ -0,0 +1,26 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Set; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceContext; | ||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | |||
8 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
9 | |||
10 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java new file mode 100644 index 00000000..0b417b06 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java | |||
@@ -0,0 +1,180 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.lang.ref.WeakReference; | ||
4 | import java.util.ArrayList; | ||
5 | import java.util.HashMap; | ||
6 | import java.util.List; | ||
7 | import java.util.Map; | ||
8 | |||
9 | import org.eclipse.emf.common.util.URI; | ||
10 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
11 | import org.eclipse.xtext.util.IDisposable; | ||
12 | import org.eclipse.xtext.web.server.IServiceContext; | ||
13 | import org.eclipse.xtext.web.server.IServiceResult; | ||
14 | import org.eclipse.xtext.web.server.ISession; | ||
15 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
16 | import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; | ||
17 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
18 | import org.slf4j.Logger; | ||
19 | import org.slf4j.LoggerFactory; | ||
20 | |||
21 | import com.google.common.base.Strings; | ||
22 | import com.google.inject.Injector; | ||
23 | |||
24 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind; | ||
25 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage; | ||
28 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
29 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
30 | import tools.refinery.language.web.xtext.server.push.PushWebDocument; | ||
31 | import tools.refinery.language.web.xtext.servlet.SimpleServiceContext; | ||
32 | |||
33 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java new file mode 100644 index 00000000..f74bae74 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java | |||
@@ -0,0 +1,11 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import com.google.gson.annotations.SerializedName; | ||
4 | |||
5 | public enum XtextWebErrorKind { | ||
6 | @SerializedName("request") | ||
7 | REQUEST_ERROR, | ||
8 | |||
9 | @SerializedName("server") | ||
10 | SERVER_ERROR, | ||
11 | } | ||
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java new file mode 100644 index 00000000..01d78c31 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java | |||
@@ -0,0 +1,79 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java new file mode 100644 index 00000000..8af27247 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java | |||
@@ -0,0 +1,72 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
7 | |||
8 | import com.google.gson.annotations.SerializedName; | ||
9 | |||
10 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java new file mode 100644 index 00000000..c9432e1c --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java | |||
@@ -0,0 +1,81 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java new file mode 100644 index 00000000..959749f8 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java | |||
@@ -0,0 +1,57 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Objects; | ||
5 | |||
6 | import com.google.gson.annotations.SerializedName; | ||
7 | |||
8 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java new file mode 100644 index 00000000..3bd13047 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java | |||
@@ -0,0 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage { | ||
4 | } | ||
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java new file mode 100644 index 00000000..79a284db --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java | |||
@@ -0,0 +1,15 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceResult; | ||
4 | |||
5 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
6 | |||
7 | @FunctionalInterface | ||
8 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java new file mode 100644 index 00000000..c7b8108d --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java | |||
@@ -0,0 +1,23 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
5 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
6 | |||
7 | import com.google.inject.Singleton; | ||
8 | |||
9 | import tools.refinery.language.web.xtext.server.SubscribingServiceContext; | ||
10 | |||
11 | @Singleton | ||
12 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java new file mode 100644 index 00000000..906b9e30 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java | |||
@@ -0,0 +1,89 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import java.util.ArrayList; | ||
4 | import java.util.HashMap; | ||
5 | import java.util.List; | ||
6 | import java.util.Map; | ||
7 | |||
8 | import org.eclipse.xtext.util.CancelIndicator; | ||
9 | import org.eclipse.xtext.web.server.IServiceResult; | ||
10 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
11 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
12 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import com.google.common.collect.ImmutableList; | ||
17 | |||
18 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
19 | |||
20 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java new file mode 100644 index 00000000..b3666a86 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java | |||
@@ -0,0 +1,68 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.service.OperationCanceledManager; | ||
4 | import org.eclipse.xtext.util.CancelIndicator; | ||
5 | import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork; | ||
6 | import org.eclipse.xtext.web.server.IServiceResult; | ||
7 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
8 | import org.eclipse.xtext.web.server.model.IXtextWebDocument; | ||
9 | import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; | ||
10 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
11 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
12 | import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService; | ||
13 | import org.eclipse.xtext.web.server.validation.ValidationService; | ||
14 | |||
15 | import com.google.inject.Inject; | ||
16 | |||
17 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java new file mode 100644 index 00000000..fc45f74a --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java | |||
@@ -0,0 +1,33 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
5 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
6 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
7 | |||
8 | import com.google.inject.Inject; | ||
9 | import com.google.inject.Provider; | ||
10 | import 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 | ||
19 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java new file mode 100644 index 00000000..43e37160 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java | |||
@@ -0,0 +1,26 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Set; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.IServiceContext; | ||
7 | import org.eclipse.xtext.web.server.ISession; | ||
8 | |||
9 | import com.google.common.collect.ImmutableSet; | ||
10 | |||
11 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java new file mode 100644 index 00000000..09c055a2 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java | |||
@@ -0,0 +1,35 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.HashMap; | ||
4 | import java.util.Map; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | import org.eclipse.xtext.xbase.lib.Functions.Function0; | ||
8 | |||
9 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java new file mode 100644 index 00000000..0cd229e8 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java | |||
@@ -0,0 +1,9 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java new file mode 100644 index 00000000..fd41f213 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java | |||
@@ -0,0 +1,133 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.io.Reader; | ||
5 | |||
6 | import org.eclipse.jetty.websocket.api.Session; | ||
7 | import org.eclipse.jetty.websocket.api.StatusCode; | ||
8 | import org.eclipse.jetty.websocket.api.WriteCallback; | ||
9 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; | ||
10 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; | ||
12 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
13 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
14 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
15 | import org.eclipse.xtext.web.server.ISession; | ||
16 | import org.slf4j.Logger; | ||
17 | import org.slf4j.LoggerFactory; | ||
18 | |||
19 | import com.google.gson.Gson; | ||
20 | import com.google.gson.JsonIOException; | ||
21 | import com.google.gson.JsonParseException; | ||
22 | |||
23 | import tools.refinery.language.web.xtext.server.ResponseHandler; | ||
24 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
25 | import tools.refinery.language.web.xtext.server.TransactionExecutor; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
28 | |||
29 | @WebSocket | ||
30 | public 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/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java new file mode 100644 index 00000000..942ca380 --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java | |||
@@ -0,0 +1,83 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.time.Duration; | ||
5 | import java.util.Set; | ||
6 | |||
7 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; | ||
8 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; | ||
9 | import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; | ||
10 | import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; | ||
11 | import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; | ||
12 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import jakarta.servlet.ServletConfig; | ||
17 | import jakarta.servlet.ServletException; | ||
18 | |||
19 | public 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/subprojects/language-web/src/main/js/App.tsx b/subprojects/language-web/src/main/js/App.tsx new file mode 100644 index 00000000..54f92f9a --- /dev/null +++ b/subprojects/language-web/src/main/js/App.tsx | |||
@@ -0,0 +1,60 @@ | |||
1 | import AppBar from '@mui/material/AppBar'; | ||
2 | import Box from '@mui/material/Box'; | ||
3 | import IconButton from '@mui/material/IconButton'; | ||
4 | import Toolbar from '@mui/material/Toolbar'; | ||
5 | import Typography from '@mui/material/Typography'; | ||
6 | import MenuIcon from '@mui/icons-material/Menu'; | ||
7 | import React from 'react'; | ||
8 | |||
9 | import { EditorArea } from './editor/EditorArea'; | ||
10 | import { EditorButtons } from './editor/EditorButtons'; | ||
11 | import { GenerateButton } from './editor/GenerateButton'; | ||
12 | |||
13 | export 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/subprojects/language-web/src/main/js/RootStore.tsx b/subprojects/language-web/src/main/js/RootStore.tsx new file mode 100644 index 00000000..baf0b61e --- /dev/null +++ b/subprojects/language-web/src/main/js/RootStore.tsx | |||
@@ -0,0 +1,39 @@ | |||
1 | import React, { createContext, useContext } from 'react'; | ||
2 | |||
3 | import { EditorStore } from './editor/EditorStore'; | ||
4 | import { ThemeStore } from './theme/ThemeStore'; | ||
5 | |||
6 | export 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 | |||
17 | const StoreContext = createContext<RootStore | undefined>(undefined); | ||
18 | |||
19 | export interface RootStoreProviderProps { | ||
20 | children: JSX.Element; | ||
21 | |||
22 | rootStore: RootStore; | ||
23 | } | ||
24 | |||
25 | export function RootStoreProvider({ children, rootStore }: RootStoreProviderProps): JSX.Element { | ||
26 | return ( | ||
27 | <StoreContext.Provider value={rootStore}> | ||
28 | {children} | ||
29 | </StoreContext.Provider> | ||
30 | ); | ||
31 | } | ||
32 | |||
33 | export 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/subprojects/language-web/src/main/js/editor/EditorArea.tsx b/subprojects/language-web/src/main/js/editor/EditorArea.tsx new file mode 100644 index 00000000..dba20f6e --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/EditorArea.tsx | |||
@@ -0,0 +1,152 @@ | |||
1 | import { Command, EditorView } from '@codemirror/view'; | ||
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
4 | import { observer } from 'mobx-react-lite'; | ||
5 | import React, { | ||
6 | useCallback, | ||
7 | useEffect, | ||
8 | useRef, | ||
9 | useState, | ||
10 | } from 'react'; | ||
11 | |||
12 | import { EditorParent } from './EditorParent'; | ||
13 | import { useRootStore } from '../RootStore'; | ||
14 | import { getLogger } from '../utils/logger'; | ||
15 | |||
16 | const log = getLogger('editor.EditorArea'); | ||
17 | |||
18 | function 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 | |||
66 | function 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 | |||
73 | export 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/subprojects/language-web/src/main/js/editor/EditorButtons.tsx b/subprojects/language-web/src/main/js/editor/EditorButtons.tsx new file mode 100644 index 00000000..150aa00d --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/EditorButtons.tsx | |||
@@ -0,0 +1,98 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | import { observer } from 'mobx-react-lite'; | ||
3 | import IconButton from '@mui/material/IconButton'; | ||
4 | import Stack from '@mui/material/Stack'; | ||
5 | import ToggleButton from '@mui/material/ToggleButton'; | ||
6 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | ||
7 | import CheckIcon from '@mui/icons-material/Check'; | ||
8 | import ErrorIcon from '@mui/icons-material/Error'; | ||
9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | ||
10 | import FormatPaint from '@mui/icons-material/FormatPaint'; | ||
11 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | ||
12 | import RedoIcon from '@mui/icons-material/Redo'; | ||
13 | import SearchIcon from '@mui/icons-material/Search'; | ||
14 | import UndoIcon from '@mui/icons-material/Undo'; | ||
15 | import WarningIcon from '@mui/icons-material/Warning'; | ||
16 | import React from 'react'; | ||
17 | |||
18 | import { useRootStore } from '../RootStore'; | ||
19 | |||
20 | // Exhastive switch as proven by TypeScript. | ||
21 | // eslint-disable-next-line consistent-return | ||
22 | function 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 | |||
35 | export 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/subprojects/language-web/src/main/js/editor/EditorParent.ts b/subprojects/language-web/src/main/js/editor/EditorParent.ts new file mode 100644 index 00000000..94ca24ea --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/EditorParent.ts | |||
@@ -0,0 +1,205 @@ | |||
1 | import { 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 | */ | ||
12 | function 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 | |||
20 | export 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/subprojects/language-web/src/main/js/editor/EditorStore.ts b/subprojects/language-web/src/main/js/editor/EditorStore.ts new file mode 100644 index 00000000..5760de28 --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/EditorStore.ts | |||
@@ -0,0 +1,289 @@ | |||
1 | import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; | ||
2 | import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; | ||
3 | import { defaultKeymap, indentWithTab } from '@codemirror/commands'; | ||
4 | import { commentKeymap } from '@codemirror/comment'; | ||
5 | import { foldGutter, foldKeymap } from '@codemirror/fold'; | ||
6 | import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; | ||
7 | import { classHighlightStyle } from '@codemirror/highlight'; | ||
8 | import { | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | redo, | ||
12 | redoDepth, | ||
13 | undo, | ||
14 | undoDepth, | ||
15 | } from '@codemirror/history'; | ||
16 | import { indentOnInput } from '@codemirror/language'; | ||
17 | import { | ||
18 | Diagnostic, | ||
19 | lintKeymap, | ||
20 | setDiagnostics, | ||
21 | } from '@codemirror/lint'; | ||
22 | import { bracketMatching } from '@codemirror/matchbrackets'; | ||
23 | import { rectangularSelection } from '@codemirror/rectangular-selection'; | ||
24 | import { searchConfig, searchKeymap } from '@codemirror/search'; | ||
25 | import { | ||
26 | EditorState, | ||
27 | StateCommand, | ||
28 | StateEffect, | ||
29 | Transaction, | ||
30 | TransactionSpec, | ||
31 | } from '@codemirror/state'; | ||
32 | import { | ||
33 | drawSelection, | ||
34 | EditorView, | ||
35 | highlightActiveLine, | ||
36 | highlightSpecialChars, | ||
37 | keymap, | ||
38 | } from '@codemirror/view'; | ||
39 | import { | ||
40 | makeAutoObservable, | ||
41 | observable, | ||
42 | reaction, | ||
43 | } from 'mobx'; | ||
44 | |||
45 | import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; | ||
46 | import { problemLanguageSupport } from '../language/problemLanguageSupport'; | ||
47 | import { | ||
48 | IHighlightRange, | ||
49 | semanticHighlighting, | ||
50 | setSemanticHighlighting, | ||
51 | } from './semanticHighlighting'; | ||
52 | import type { ThemeStore } from '../theme/ThemeStore'; | ||
53 | import { getLogger } from '../utils/logger'; | ||
54 | import { XtextClient } from '../xtext/XtextClient'; | ||
55 | |||
56 | const log = getLogger('editor.EditorStore'); | ||
57 | |||
58 | export 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/subprojects/language-web/src/main/js/editor/GenerateButton.tsx b/subprojects/language-web/src/main/js/editor/GenerateButton.tsx new file mode 100644 index 00000000..3834cec4 --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/GenerateButton.tsx | |||
@@ -0,0 +1,44 @@ | |||
1 | import { observer } from 'mobx-react-lite'; | ||
2 | import Button from '@mui/material/Button'; | ||
3 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; | ||
4 | import React from 'react'; | ||
5 | |||
6 | import { useRootStore } from '../RootStore'; | ||
7 | |||
8 | const GENERATE_LABEL = 'Generate'; | ||
9 | |||
10 | export 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/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts b/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts new file mode 100644 index 00000000..2d630c20 --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/decorationSetExtension.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; | ||
2 | import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; | ||
3 | |||
4 | export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; | ||
5 | |||
6 | export 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/subprojects/language-web/src/main/js/editor/findOccurrences.ts b/subprojects/language-web/src/main/js/editor/findOccurrences.ts new file mode 100644 index 00000000..92102746 --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/findOccurrences.ts | |||
@@ -0,0 +1,35 @@ | |||
1 | import { Range, RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IOccurrence { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | } | ||
12 | |||
13 | const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); | ||
14 | |||
15 | const writeDecoration = Decoration.mark({ | ||
16 | class: 'cm-problem-write', | ||
17 | }); | ||
18 | |||
19 | const readDecoration = Decoration.mark({ | ||
20 | class: 'cm-problem-read', | ||
21 | }); | ||
22 | |||
23 | export 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 | |||
35 | export { findOccurrences }; | ||
diff --git a/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts b/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts new file mode 100644 index 00000000..2aed421b --- /dev/null +++ b/subprojects/language-web/src/main/js/editor/semanticHighlighting.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IHighlightRange { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | |||
12 | classes: string[]; | ||
13 | } | ||
14 | |||
15 | const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); | ||
16 | |||
17 | export 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 | |||
24 | export { semanticHighlighting }; | ||
diff --git a/subprojects/language-web/src/main/js/global.d.ts b/subprojects/language-web/src/main/js/global.d.ts new file mode 100644 index 00000000..0533a46e --- /dev/null +++ b/subprojects/language-web/src/main/js/global.d.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | declare const DEBUG: boolean; | ||
2 | |||
3 | declare const PACKAGE_NAME: string; | ||
4 | |||
5 | declare const PACKAGE_VERSION: string; | ||
6 | |||
7 | declare 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/subprojects/language-web/src/main/js/index.tsx b/subprojects/language-web/src/main/js/index.tsx new file mode 100644 index 00000000..d368c9ba --- /dev/null +++ b/subprojects/language-web/src/main/js/index.tsx | |||
@@ -0,0 +1,69 @@ | |||
1 | import React from 'react'; | ||
2 | import { render } from 'react-dom'; | ||
3 | import CssBaseline from '@mui/material/CssBaseline'; | ||
4 | |||
5 | import { App } from './App'; | ||
6 | import { RootStore, RootStoreProvider } from './RootStore'; | ||
7 | import { ThemeProvider } from './theme/ThemeProvider'; | ||
8 | |||
9 | import '../css/index.scss'; | ||
10 | |||
11 | const initialValue = `class Family { | ||
12 | contains Person[] members | ||
13 | } | ||
14 | |||
15 | class Person { | ||
16 | Person[] children opposite parent | ||
17 | Person[0..1] parent opposite children | ||
18 | int age | ||
19 | TaxStatus taxStatus | ||
20 | } | ||
21 | |||
22 | enum TaxStatus { | ||
23 | child, student, adult, retired | ||
24 | } | ||
25 | |||
26 | % A child cannot have any dependents. | ||
27 | pred invalidTaxStatus(Person p) <-> | ||
28 | taxStatus(p, child), | ||
29 | children(p, _q) | ||
30 | ; taxStatus(p, retired), | ||
31 | parent(p, q), | ||
32 | !taxStatus(q, retired). | ||
33 | |||
34 | direct 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 | |||
41 | indiv family. | ||
42 | Family(family). | ||
43 | members(family, anne). | ||
44 | members(family, bob). | ||
45 | members(family, ciri). | ||
46 | children(anne, ciri). | ||
47 | ?children(bob, ciri). | ||
48 | default children(ciri, *): false. | ||
49 | taxStatus(anne, adult). | ||
50 | age(anne, 35). | ||
51 | bobAge: 27. | ||
52 | age(bob, bobAge). | ||
53 | !age(ciri, bobAge). | ||
54 | |||
55 | scope Family = 1, Person += 5..10. | ||
56 | `; | ||
57 | |||
58 | const rootStore = new RootStore(initialValue); | ||
59 | |||
60 | const app = ( | ||
61 | <RootStoreProvider rootStore={rootStore}> | ||
62 | <ThemeProvider> | ||
63 | <CssBaseline /> | ||
64 | <App /> | ||
65 | </ThemeProvider> | ||
66 | </RootStoreProvider> | ||
67 | ); | ||
68 | |||
69 | render(app, document.getElementById('app')); | ||
diff --git a/subprojects/language-web/src/main/js/language/folding.ts b/subprojects/language-web/src/main/js/language/folding.ts new file mode 100644 index 00000000..5d51f796 --- /dev/null +++ b/subprojects/language-web/src/main/js/language/folding.ts | |||
@@ -0,0 +1,115 @@ | |||
1 | import { EditorState } from '@codemirror/state'; | ||
2 | import type { SyntaxNode } from '@lezer/common'; | ||
3 | |||
4 | export 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 | */ | ||
12 | export 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 | */ | ||
50 | export 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 | */ | ||
78 | function 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 | |||
103 | export function foldWholeNode(node: SyntaxNode): FoldRange { | ||
104 | return { | ||
105 | from: node.from, | ||
106 | to: node.to, | ||
107 | }; | ||
108 | } | ||
109 | |||
110 | export 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/subprojects/language-web/src/main/js/language/indentation.ts b/subprojects/language-web/src/main/js/language/indentation.ts new file mode 100644 index 00000000..6d36ed3b --- /dev/null +++ b/subprojects/language-web/src/main/js/language/indentation.ts | |||
@@ -0,0 +1,87 @@ | |||
1 | import { 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 | */ | ||
13 | function 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 | */ | ||
61 | function 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 | |||
69 | export function indentBlockComment(): number { | ||
70 | // Do not indent. | ||
71 | return -1; | ||
72 | } | ||
73 | |||
74 | export function indentDeclaration(context: TreeIndentContext): number { | ||
75 | return indentDeclarationStrategy(context, 1); | ||
76 | } | ||
77 | |||
78 | export 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/subprojects/language-web/src/main/js/language/problem.grammar b/subprojects/language-web/src/main/js/language/problem.grammar new file mode 100644 index 00000000..bccc2e31 --- /dev/null +++ b/subprojects/language-web/src/main/js/language/problem.grammar | |||
@@ -0,0 +1,149 @@ | |||
1 | @detectDelim | ||
2 | |||
3 | @external prop implicitCompletion from '../../../../src/main/js/language/props.ts' | ||
4 | |||
5 | @top Problem { statement* } | ||
6 | |||
7 | statement { | ||
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 | |||
45 | ReferenceDeclaration { | ||
46 | (kw<"refers"> | kw<"contains">)? | ||
47 | RelationName | ||
48 | RelationName | ||
49 | ( "[" Multiplicity? "]" )? | ||
50 | (kw<"opposite"> RelationName)? | ||
51 | ";"? | ||
52 | } | ||
53 | |||
54 | Parameter { RelationName? VariableName } | ||
55 | |||
56 | Conjunction { ("," | Literal)+ } | ||
57 | |||
58 | OrOp { ";" } | ||
59 | |||
60 | Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? } | ||
61 | |||
62 | Atom { RelationName "+"? ParameterList<Argument> } | ||
63 | |||
64 | Action { ("," | ActionLiteral)+ } | ||
65 | |||
66 | ActionLiteral { | ||
67 | ckw<"new"> VariableName | | ||
68 | ckw<"delete"> VariableName | | ||
69 | Literal | ||
70 | } | ||
71 | |||
72 | Argument { VariableName | Constant } | ||
73 | |||
74 | AssertionArgument { NodeName | StarArgument | Constant } | ||
75 | |||
76 | Constant { Real | String } | ||
77 | |||
78 | LogicValue { | ||
79 | ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> | ||
80 | } | ||
81 | |||
82 | ScopeElement { RelationName ("=" | "+=") Multiplicity } | ||
83 | |||
84 | Multiplicity { (IntMult "..")? (IntMult | StarMult)} | ||
85 | |||
86 | RelationName { QualifiedName } | ||
87 | |||
88 | RuleName { QualifiedName } | ||
89 | |||
90 | IndividualNodeName { QualifiedName } | ||
91 | |||
92 | VariableName { QualifiedName } | ||
93 | |||
94 | NodeName { QualifiedName } | ||
95 | |||
96 | QualifiedName[implicitCompletion=true] { identifier ("::" identifier)* } | ||
97 | |||
98 | kw<term> { @specialize[@name={term},implicitCompletion=true]<identifier, term> } | ||
99 | |||
100 | ckw<term> { @extend[@name={term},implicitCompletion=true]<identifier, term> } | ||
101 | |||
102 | ParameterList<content> { "(" sep<",", content> ")" } | ||
103 | |||
104 | sep<separator, content> { sep1<separator, content>? } | ||
105 | |||
106 | sep1<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/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts b/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts new file mode 100644 index 00000000..6508a2c0 --- /dev/null +++ b/subprojects/language-web/src/main/js/language/problemLanguageSupport.ts | |||
@@ -0,0 +1,92 @@ | |||
1 | import { styleTags, tags as t } from '@codemirror/highlight'; | ||
2 | import { | ||
3 | foldInside, | ||
4 | foldNodeProp, | ||
5 | indentNodeProp, | ||
6 | indentUnit, | ||
7 | LanguageSupport, | ||
8 | LRLanguage, | ||
9 | } from '@codemirror/language'; | ||
10 | import { LRParser } from '@lezer/lr'; | ||
11 | |||
12 | import { parser } from '../../../../build/generated/sources/lezer/problem'; | ||
13 | import { | ||
14 | foldBlockComment, | ||
15 | foldConjunction, | ||
16 | foldDeclaration, | ||
17 | foldWholeNode, | ||
18 | } from './folding'; | ||
19 | import { | ||
20 | indentBlockComment, | ||
21 | indentDeclaration, | ||
22 | indentPredicateOrRule, | ||
23 | } from './indentation'; | ||
24 | |||
25 | const 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 | |||
74 | const 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 | |||
88 | export function problemLanguageSupport(): LanguageSupport { | ||
89 | return new LanguageSupport(problemLanguage, [ | ||
90 | indentUnit.of(' '), | ||
91 | ]); | ||
92 | } | ||
diff --git a/subprojects/language-web/src/main/js/language/props.ts b/subprojects/language-web/src/main/js/language/props.ts new file mode 100644 index 00000000..8e488bf5 --- /dev/null +++ b/subprojects/language-web/src/main/js/language/props.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | import { NodeProp } from '@lezer/common'; | ||
2 | |||
3 | export const implicitCompletion = new NodeProp({ | ||
4 | deserialize(s: string) { | ||
5 | return s === 'true'; | ||
6 | }, | ||
7 | }); | ||
diff --git a/subprojects/language-web/src/main/js/theme/EditorTheme.ts b/subprojects/language-web/src/main/js/theme/EditorTheme.ts new file mode 100644 index 00000000..1b0dd5de --- /dev/null +++ b/subprojects/language-web/src/main/js/theme/EditorTheme.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import type { PaletteMode } from '@mui/material'; | ||
2 | |||
3 | import cssVariables from '../../css/themeVariables.module.scss'; | ||
4 | |||
5 | export enum EditorTheme { | ||
6 | Light, | ||
7 | Dark, | ||
8 | } | ||
9 | |||
10 | export 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 | |||
42 | export const DEFAULT_THEME = EditorTheme.Dark; | ||
43 | |||
44 | export 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/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx b/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx new file mode 100644 index 00000000..f5b50be1 --- /dev/null +++ b/subprojects/language-web/src/main/js/theme/ThemeProvider.tsx | |||
@@ -0,0 +1,15 @@ | |||
1 | import { observer } from 'mobx-react-lite'; | ||
2 | import { ThemeProvider as MaterialUiThemeProvider } from '@mui/material/styles'; | ||
3 | import React from 'react'; | ||
4 | |||
5 | import { useRootStore } from '../RootStore'; | ||
6 | |||
7 | export 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/subprojects/language-web/src/main/js/theme/ThemeStore.ts b/subprojects/language-web/src/main/js/theme/ThemeStore.ts new file mode 100644 index 00000000..ffaf6dde --- /dev/null +++ b/subprojects/language-web/src/main/js/theme/ThemeStore.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | import { makeAutoObservable } from 'mobx'; | ||
2 | import { | ||
3 | Theme, | ||
4 | createTheme, | ||
5 | responsiveFontSizes, | ||
6 | } from '@mui/material/styles'; | ||
7 | |||
8 | import { | ||
9 | EditorTheme, | ||
10 | EditorThemeData, | ||
11 | DEFAULT_THEME, | ||
12 | EDITOR_THEMES, | ||
13 | } from './EditorTheme'; | ||
14 | |||
15 | export 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/subprojects/language-web/src/main/js/utils/ConditionVariable.ts b/subprojects/language-web/src/main/js/utils/ConditionVariable.ts new file mode 100644 index 00000000..0910dfa6 --- /dev/null +++ b/subprojects/language-web/src/main/js/utils/ConditionVariable.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | import { getLogger } from './logger'; | ||
2 | import { PendingTask } from './PendingTask'; | ||
3 | |||
4 | const log = getLogger('utils.ConditionVariable'); | ||
5 | |||
6 | export type Condition = () => boolean; | ||
7 | |||
8 | export 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/subprojects/language-web/src/main/js/utils/PendingTask.ts b/subprojects/language-web/src/main/js/utils/PendingTask.ts new file mode 100644 index 00000000..51b79fb0 --- /dev/null +++ b/subprojects/language-web/src/main/js/utils/PendingTask.ts | |||
@@ -0,0 +1,60 @@ | |||
1 | import { getLogger } from './logger'; | ||
2 | |||
3 | const log = getLogger('utils.PendingTask'); | ||
4 | |||
5 | export 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/subprojects/language-web/src/main/js/utils/Timer.ts b/subprojects/language-web/src/main/js/utils/Timer.ts new file mode 100644 index 00000000..8f653070 --- /dev/null +++ b/subprojects/language-web/src/main/js/utils/Timer.ts | |||
@@ -0,0 +1,33 @@ | |||
1 | export 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/subprojects/language-web/src/main/js/utils/logger.ts b/subprojects/language-web/src/main/js/utils/logger.ts new file mode 100644 index 00000000..306d122c --- /dev/null +++ b/subprojects/language-web/src/main/js/utils/logger.ts | |||
@@ -0,0 +1,49 @@ | |||
1 | import styles, { CSPair } from 'ansi-styles'; | ||
2 | import log from 'loglevel'; | ||
3 | import * as prefix from 'loglevel-plugin-prefix'; | ||
4 | |||
5 | const 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 | |||
13 | prefix.reg(log); | ||
14 | |||
15 | if (DEBUG) { | ||
16 | log.setLevel(log.levels.DEBUG); | ||
17 | } else { | ||
18 | log.setLevel(log.levels.WARN); | ||
19 | } | ||
20 | |||
21 | if ('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 | |||
38 | const appLogger = log.getLogger(PACKAGE_NAME); | ||
39 | |||
40 | appLogger.info('Version:', PACKAGE_NAME, PACKAGE_VERSION); | ||
41 | appLogger.info('Debug mode:', DEBUG); | ||
42 | |||
43 | export function getLoggerFromRoot(name: string | symbol): log.Logger { | ||
44 | return log.getLogger(name); | ||
45 | } | ||
46 | |||
47 | export function getLogger(name: string | symbol): log.Logger { | ||
48 | return getLoggerFromRoot(`${PACKAGE_NAME}.${name.toString()}`); | ||
49 | } | ||
diff --git a/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts b/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts new file mode 100644 index 00000000..8b872e06 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/ContentAssistService.ts | |||
@@ -0,0 +1,219 @@ | |||
1 | import type { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import { syntaxTree } from '@codemirror/language'; | ||
7 | import type { Transaction } from '@codemirror/state'; | ||
8 | import escapeStringRegexp from 'escape-string-regexp'; | ||
9 | |||
10 | import { implicitCompletion } from '../language/props'; | ||
11 | import type { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import type { ContentAssistEntry } from './xtextServiceResults'; | ||
14 | |||
15 | const PROPOSALS_LIMIT = 1000; | ||
16 | |||
17 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | ||
18 | |||
19 | const HIGH_PRIORITY_KEYWORDS = ['<->', '~>']; | ||
20 | |||
21 | const log = getLogger('xtext.ContentAssistService'); | ||
22 | |||
23 | interface IFoundToken { | ||
24 | from: number; | ||
25 | |||
26 | to: number; | ||
27 | |||
28 | implicitCompletion: boolean; | ||
29 | |||
30 | text: string; | ||
31 | } | ||
32 | |||
33 | function 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 | |||
51 | function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { | ||
52 | return token !== null | ||
53 | && token.implicitCompletion | ||
54 | && context.pos - token.from >= 2; | ||
55 | } | ||
56 | |||
57 | function 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 | |||
70 | function 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 | |||
97 | export 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/subprojects/language-web/src/main/js/xtext/HighlightingService.ts b/subprojects/language-web/src/main/js/xtext/HighlightingService.ts new file mode 100644 index 00000000..dfbb4a19 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/HighlightingService.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import type { EditorStore } from '../editor/EditorStore'; | ||
2 | import type { IHighlightRange } from '../editor/semanticHighlighting'; | ||
3 | import type { UpdateService } from './UpdateService'; | ||
4 | import { highlightingResult } from './xtextServiceResults'; | ||
5 | |||
6 | export 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/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts b/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts new file mode 100644 index 00000000..bc865537 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/OccurrencesService.ts | |||
@@ -0,0 +1,127 @@ | |||
1 | import { Transaction } from '@codemirror/state'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { IOccurrence } from '../editor/findOccurrences'; | ||
5 | import type { UpdateService } from './UpdateService'; | ||
6 | import { getLogger } from '../utils/logger'; | ||
7 | import { Timer } from '../utils/Timer'; | ||
8 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
9 | import { | ||
10 | isConflictResult, | ||
11 | occurrencesResult, | ||
12 | TextRegion, | ||
13 | } from './xtextServiceResults'; | ||
14 | |||
15 | const 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. | ||
19 | const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; | ||
20 | |||
21 | const log = getLogger('xtext.OccurrencesService'); | ||
22 | |||
23 | function 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 | |||
36 | export 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/subprojects/language-web/src/main/js/xtext/UpdateService.ts b/subprojects/language-web/src/main/js/xtext/UpdateService.ts new file mode 100644 index 00000000..e78944a9 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/UpdateService.ts | |||
@@ -0,0 +1,363 @@ | |||
1 | import { | ||
2 | ChangeDesc, | ||
3 | ChangeSet, | ||
4 | ChangeSpec, | ||
5 | StateEffect, | ||
6 | Transaction, | ||
7 | } from '@codemirror/state'; | ||
8 | import { nanoid } from 'nanoid'; | ||
9 | |||
10 | import type { EditorStore } from '../editor/EditorStore'; | ||
11 | import type { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
12 | import { ConditionVariable } from '../utils/ConditionVariable'; | ||
13 | import { getLogger } from '../utils/logger'; | ||
14 | import { Timer } from '../utils/Timer'; | ||
15 | import { | ||
16 | ContentAssistEntry, | ||
17 | contentAssistResult, | ||
18 | documentStateResult, | ||
19 | formattingResult, | ||
20 | isConflictResult, | ||
21 | } from './xtextServiceResults'; | ||
22 | |||
23 | const UPDATE_TIMEOUT_MS = 500; | ||
24 | |||
25 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
26 | |||
27 | const log = getLogger('xtext.UpdateService'); | ||
28 | |||
29 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | ||
30 | |||
31 | export interface IAbortSignal { | ||
32 | aborted: boolean; | ||
33 | } | ||
34 | |||
35 | export 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/subprojects/language-web/src/main/js/xtext/ValidationService.ts b/subprojects/language-web/src/main/js/xtext/ValidationService.ts new file mode 100644 index 00000000..ff7d3700 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/ValidationService.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { UpdateService } from './UpdateService'; | ||
5 | import { validationResult } from './xtextServiceResults'; | ||
6 | |||
7 | export 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/subprojects/language-web/src/main/js/xtext/XtextClient.ts b/subprojects/language-web/src/main/js/xtext/XtextClient.ts new file mode 100644 index 00000000..0898e725 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/XtextClient.ts | |||
@@ -0,0 +1,86 @@ | |||
1 | import type { | ||
2 | CompletionContext, | ||
3 | CompletionResult, | ||
4 | } from '@codemirror/autocomplete'; | ||
5 | import type { Transaction } from '@codemirror/state'; | ||
6 | |||
7 | import type { EditorStore } from '../editor/EditorStore'; | ||
8 | import { ContentAssistService } from './ContentAssistService'; | ||
9 | import { HighlightingService } from './HighlightingService'; | ||
10 | import { OccurrencesService } from './OccurrencesService'; | ||
11 | import { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import { ValidationService } from './ValidationService'; | ||
14 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
15 | import { XtextWebPushService } from './xtextMessages'; | ||
16 | |||
17 | const log = getLogger('xtext.XtextClient'); | ||
18 | |||
19 | export 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/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts new file mode 100644 index 00000000..2ce20a54 --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/XtextWebSocketClient.ts | |||
@@ -0,0 +1,362 @@ | |||
1 | import { nanoid } from 'nanoid'; | ||
2 | |||
3 | import { getLogger } from '../utils/logger'; | ||
4 | import { PendingTask } from '../utils/PendingTask'; | ||
5 | import { Timer } from '../utils/Timer'; | ||
6 | import { | ||
7 | xtextWebErrorResponse, | ||
8 | XtextWebRequest, | ||
9 | xtextWebOkResponse, | ||
10 | xtextWebPushMessage, | ||
11 | XtextWebPushService, | ||
12 | } from './xtextMessages'; | ||
13 | import { pongResult } from './xtextServiceResults'; | ||
14 | |||
15 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | ||
16 | |||
17 | const WEBSOCKET_CLOSE_OK = 1000; | ||
18 | |||
19 | const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; | ||
20 | |||
21 | const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
22 | |||
23 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | ||
24 | |||
25 | const PING_TIMEOUT_MS = 10 * 1000; | ||
26 | |||
27 | const REQUEST_TIMEOUT_MS = 1000; | ||
28 | |||
29 | const log = getLogger('xtext.XtextWebSocketClient'); | ||
30 | |||
31 | export type ReconnectHandler = () => void; | ||
32 | |||
33 | export type PushHandler = ( | ||
34 | resourceId: string, | ||
35 | stateId: string, | ||
36 | service: XtextWebPushService, | ||
37 | data: unknown, | ||
38 | ) => void; | ||
39 | |||
40 | enum State { | ||
41 | Initial, | ||
42 | Opening, | ||
43 | TabVisible, | ||
44 | TabHiddenIdle, | ||
45 | TabHiddenWaiting, | ||
46 | Error, | ||
47 | TimedOut, | ||
48 | } | ||
49 | |||
50 | export 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/subprojects/language-web/src/main/js/xtext/xtextMessages.ts b/subprojects/language-web/src/main/js/xtext/xtextMessages.ts new file mode 100644 index 00000000..c4305fcf --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/xtextMessages.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import { z } from 'zod'; | ||
2 | |||
3 | export const xtextWebRequest = z.object({ | ||
4 | id: z.string().nonempty(), | ||
5 | request: z.unknown(), | ||
6 | }); | ||
7 | |||
8 | export type XtextWebRequest = z.infer<typeof xtextWebRequest>; | ||
9 | |||
10 | export const xtextWebOkResponse = z.object({ | ||
11 | id: z.string().nonempty(), | ||
12 | response: z.unknown(), | ||
13 | }); | ||
14 | |||
15 | export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>; | ||
16 | |||
17 | export const xtextWebErrorKind = z.enum(['request', 'server']); | ||
18 | |||
19 | export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>; | ||
20 | |||
21 | export const xtextWebErrorResponse = z.object({ | ||
22 | id: z.string().nonempty(), | ||
23 | error: xtextWebErrorKind, | ||
24 | message: z.string(), | ||
25 | }); | ||
26 | |||
27 | export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>; | ||
28 | |||
29 | export const xtextWebPushService = z.enum(['highlight', 'validate']); | ||
30 | |||
31 | export type XtextWebPushService = z.infer<typeof xtextWebPushService>; | ||
32 | |||
33 | export const xtextWebPushMessage = z.object({ | ||
34 | resource: z.string().nonempty(), | ||
35 | stateId: z.string().nonempty(), | ||
36 | service: xtextWebPushService, | ||
37 | push: z.unknown(), | ||
38 | }); | ||
39 | |||
40 | export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>; | ||
diff --git a/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts b/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts new file mode 100644 index 00000000..f79b059c --- /dev/null +++ b/subprojects/language-web/src/main/js/xtext/xtextServiceResults.ts | |||
@@ -0,0 +1,112 @@ | |||
1 | import { z } from 'zod'; | ||
2 | |||
3 | export const pongResult = z.object({ | ||
4 | pong: z.string().nonempty(), | ||
5 | }); | ||
6 | |||
7 | export type PongResult = z.infer<typeof pongResult>; | ||
8 | |||
9 | export const documentStateResult = z.object({ | ||
10 | stateId: z.string().nonempty(), | ||
11 | }); | ||
12 | |||
13 | export type DocumentStateResult = z.infer<typeof documentStateResult>; | ||
14 | |||
15 | export const conflict = z.enum(['invalidStateId', 'canceled']); | ||
16 | |||
17 | export type Conflict = z.infer<typeof conflict>; | ||
18 | |||
19 | export const serviceConflictResult = z.object({ | ||
20 | conflict, | ||
21 | }); | ||
22 | |||
23 | export type ServiceConflictResult = z.infer<typeof serviceConflictResult>; | ||
24 | |||
25 | export function isConflictResult(result: unknown, conflictType: Conflict): boolean { | ||
26 | const parsedConflictResult = serviceConflictResult.safeParse(result); | ||
27 | return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; | ||
28 | } | ||
29 | |||
30 | export const severity = z.enum(['error', 'warning', 'info', 'ignore']); | ||
31 | |||
32 | export type Severity = z.infer<typeof severity>; | ||
33 | |||
34 | export 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 | |||
43 | export type Issue = z.infer<typeof issue>; | ||
44 | |||
45 | export const validationResult = z.object({ | ||
46 | issues: issue.array(), | ||
47 | }); | ||
48 | |||
49 | export type ValidationResult = z.infer<typeof validationResult>; | ||
50 | |||
51 | export const replaceRegion = z.object({ | ||
52 | offset: z.number().int().nonnegative(), | ||
53 | length: z.number().int().nonnegative(), | ||
54 | text: z.string(), | ||
55 | }); | ||
56 | |||
57 | export type ReplaceRegion = z.infer<typeof replaceRegion>; | ||
58 | |||
59 | export const textRegion = z.object({ | ||
60 | offset: z.number().int().nonnegative(), | ||
61 | length: z.number().int().nonnegative(), | ||
62 | }); | ||
63 | |||
64 | export type TextRegion = z.infer<typeof textRegion>; | ||
65 | |||
66 | export 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 | |||
78 | export type ContentAssistEntry = z.infer<typeof contentAssistEntry>; | ||
79 | |||
80 | export const contentAssistResult = documentStateResult.extend({ | ||
81 | entries: contentAssistEntry.array(), | ||
82 | }); | ||
83 | |||
84 | export type ContentAssistResult = z.infer<typeof contentAssistResult>; | ||
85 | |||
86 | export const highlightingRegion = z.object({ | ||
87 | offset: z.number().int().nonnegative(), | ||
88 | length: z.number().int().nonnegative(), | ||
89 | styleClasses: z.string().nonempty().array(), | ||
90 | }); | ||
91 | |||
92 | export type HighlightingRegion = z.infer<typeof highlightingRegion>; | ||
93 | |||
94 | export const highlightingResult = z.object({ | ||
95 | regions: highlightingRegion.array(), | ||
96 | }); | ||
97 | |||
98 | export type HighlightingResult = z.infer<typeof highlightingResult>; | ||
99 | |||
100 | export const occurrencesResult = documentStateResult.extend({ | ||
101 | writeRegions: textRegion.array(), | ||
102 | readRegions: textRegion.array(), | ||
103 | }); | ||
104 | |||
105 | export type OccurrencesResult = z.infer<typeof occurrencesResult>; | ||
106 | |||
107 | export const formattingResult = documentStateResult.extend({ | ||
108 | formattedText: z.string(), | ||
109 | replaceRegion: textRegion, | ||
110 | }); | ||
111 | |||
112 | export type FormattingResult = z.infer<typeof formattingResult>; | ||
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java new file mode 100644 index 00000000..a26ce040 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java | |||
@@ -0,0 +1,204 @@ | |||
1 | package tools.refinery.language.web; | ||
2 | |||
3 | import static org.hamcrest.MatcherAssert.assertThat; | ||
4 | import static org.hamcrest.Matchers.equalTo; | ||
5 | import static org.hamcrest.Matchers.hasSize; | ||
6 | import static org.hamcrest.Matchers.instanceOf; | ||
7 | import static org.hamcrest.Matchers.startsWith; | ||
8 | import static org.junit.jupiter.api.Assertions.assertThrows; | ||
9 | |||
10 | import java.io.IOException; | ||
11 | import java.net.InetSocketAddress; | ||
12 | import java.net.URI; | ||
13 | import java.util.concurrent.CompletableFuture; | ||
14 | import java.util.concurrent.CompletionException; | ||
15 | |||
16 | import org.eclipse.jetty.http.HttpHeader; | ||
17 | import org.eclipse.jetty.http.HttpStatus; | ||
18 | import org.eclipse.jetty.server.Server; | ||
19 | import org.eclipse.jetty.servlet.ServletContextHandler; | ||
20 | import org.eclipse.jetty.servlet.ServletHolder; | ||
21 | import org.eclipse.jetty.websocket.api.Session; | ||
22 | import org.eclipse.jetty.websocket.api.StatusCode; | ||
23 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
24 | import org.eclipse.jetty.websocket.api.exceptions.UpgradeException; | ||
25 | import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; | ||
26 | import org.eclipse.jetty.websocket.client.WebSocketClient; | ||
27 | import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; | ||
28 | import org.eclipse.xtext.testing.GlobalRegistries; | ||
29 | import org.eclipse.xtext.testing.GlobalRegistries.GlobalStateMemento; | ||
30 | import org.junit.jupiter.api.AfterEach; | ||
31 | import org.junit.jupiter.api.BeforeEach; | ||
32 | import org.junit.jupiter.api.Test; | ||
33 | import org.junit.jupiter.params.ParameterizedTest; | ||
34 | import org.junit.jupiter.params.provider.ValueSource; | ||
35 | |||
36 | import tools.refinery.language.web.tests.WebSocketIntegrationTestClient; | ||
37 | import tools.refinery.language.web.xtext.servlet.XtextStatusCode; | ||
38 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; | ||
39 | |||
40 | class 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/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java new file mode 100644 index 00000000..b70d0ed5 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java | |||
@@ -0,0 +1,42 @@ | |||
1 | package tools.refinery.language.web.tests; | ||
2 | |||
3 | import java.util.ArrayList; | ||
4 | import java.util.List; | ||
5 | import java.util.concurrent.ExecutorService; | ||
6 | |||
7 | import org.eclipse.xtext.ide.ExecutorServiceProvider; | ||
8 | |||
9 | import com.google.inject.Singleton; | ||
10 | |||
11 | @Singleton | ||
12 | public 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/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java new file mode 100644 index 00000000..43c12faa --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java | |||
@@ -0,0 +1,47 @@ | |||
1 | package tools.refinery.language.web.tests; | ||
2 | |||
3 | import org.eclipse.xtext.ide.ExecutorServiceProvider; | ||
4 | import org.eclipse.xtext.util.DisposableRegistry; | ||
5 | import org.eclipse.xtext.util.Modules2; | ||
6 | |||
7 | import com.google.inject.Guice; | ||
8 | import com.google.inject.Injector; | ||
9 | |||
10 | import tools.refinery.language.ide.ProblemIdeModule; | ||
11 | import tools.refinery.language.tests.ProblemInjectorProvider; | ||
12 | import tools.refinery.language.web.ProblemWebModule; | ||
13 | import tools.refinery.language.web.ProblemWebSetup; | ||
14 | |||
15 | public 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/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java new file mode 100644 index 00000000..1468273d --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java | |||
@@ -0,0 +1,109 @@ | |||
1 | package tools.refinery.language.web.tests; | ||
2 | |||
3 | import java.util.Collection; | ||
4 | import java.util.List; | ||
5 | import java.util.concurrent.Callable; | ||
6 | import java.util.concurrent.ExecutionException; | ||
7 | import java.util.concurrent.ExecutorService; | ||
8 | import java.util.concurrent.Executors; | ||
9 | import java.util.concurrent.Future; | ||
10 | import java.util.concurrent.TimeUnit; | ||
11 | import java.util.concurrent.TimeoutException; | ||
12 | |||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | public 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/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java new file mode 100644 index 00000000..49464d27 --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java | |||
@@ -0,0 +1,98 @@ | |||
1 | package tools.refinery.language.web.tests; | ||
2 | |||
3 | import static org.junit.jupiter.api.Assertions.fail; | ||
4 | |||
5 | import java.io.IOException; | ||
6 | import java.time.Duration; | ||
7 | import java.util.ArrayList; | ||
8 | import java.util.List; | ||
9 | |||
10 | import org.eclipse.jetty.websocket.api.Session; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; | ||
12 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; | ||
13 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; | ||
14 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
15 | |||
16 | public 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/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java new file mode 100644 index 00000000..5b8fedba --- /dev/null +++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java | |||
@@ -0,0 +1,165 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import static org.hamcrest.MatcherAssert.assertThat; | ||
4 | import static org.hamcrest.Matchers.equalTo; | ||
5 | import static org.hamcrest.Matchers.hasProperty; | ||
6 | import static org.hamcrest.Matchers.instanceOf; | ||
7 | import static org.mockito.Mockito.mock; | ||
8 | import static org.mockito.Mockito.times; | ||
9 | import static org.mockito.Mockito.verify; | ||
10 | |||
11 | import java.util.List; | ||
12 | import java.util.Map; | ||
13 | |||
14 | import org.eclipse.emf.common.util.URI; | ||
15 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
16 | import org.eclipse.xtext.testing.InjectWith; | ||
17 | import org.eclipse.xtext.testing.extensions.InjectionExtension; | ||
18 | import org.eclipse.xtext.web.server.model.DocumentStateResult; | ||
19 | import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult; | ||
20 | import org.eclipse.xtext.web.server.validation.ValidationResult; | ||
21 | import org.junit.jupiter.api.BeforeEach; | ||
22 | import org.junit.jupiter.api.Test; | ||
23 | import org.junit.jupiter.api.extension.ExtendWith; | ||
24 | import org.mockito.ArgumentCaptor; | ||
25 | import org.mockito.junit.jupiter.MockitoExtension; | ||
26 | |||
27 | import com.google.inject.Inject; | ||
28 | |||
29 | import tools.refinery.language.web.tests.AwaitTerminationExecutorServiceProvider; | ||
30 | import tools.refinery.language.web.tests.ProblemWebInjectorProvider; | ||
31 | import tools.refinery.language.web.xtext.server.ResponseHandler; | ||
32 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
33 | import tools.refinery.language.web.xtext.server.TransactionExecutor; | ||
34 | import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; | ||
35 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
36 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
37 | |||
38 | @ExtendWith(MockitoExtension.class) | ||
39 | @ExtendWith(InjectionExtension.class) | ||
40 | @InjectWith(ProblemWebInjectorProvider.class) | ||
41 | class 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/subprojects/language-web/tsconfig.json b/subprojects/language-web/tsconfig.json new file mode 100644 index 00000000..cb5f6b13 --- /dev/null +++ b/subprojects/language-web/tsconfig.json | |||
@@ -0,0 +1,18 @@ | |||
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/subprojects/language-web/tsconfig.sonar.json b/subprojects/language-web/tsconfig.sonar.json new file mode 100644 index 00000000..54eef68b --- /dev/null +++ b/subprojects/language-web/tsconfig.sonar.json | |||
@@ -0,0 +1,17 @@ | |||
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/subprojects/language-web/webpack.config.js b/subprojects/language-web/webpack.config.js new file mode 100644 index 00000000..801a705c --- /dev/null +++ b/subprojects/language-web/webpack.config.js | |||
@@ -0,0 +1,232 @@ | |||
1 | const fs = require('fs'); | ||
2 | const path = require('path'); | ||
3 | |||
4 | const { DefinePlugin } = require('webpack'); | ||
5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); | ||
6 | const HtmlWebpackInjectPreload = require('@principalstudio/html-webpack-inject-preload'); | ||
7 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); | ||
8 | const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity'); | ||
9 | |||
10 | const packageInfo = require('./package.json'); | ||
11 | |||
12 | const currentNodeEnv = process.env.NODE_ENV || 'development'; | ||
13 | const devMode = currentNodeEnv !== 'production'; | ||
14 | const outputPath = path.resolve(__dirname, 'build/webpack', currentNodeEnv); | ||
15 | |||
16 | const portNumberOrElse = (envName, fallback) => { | ||
17 | const value = process.env[envName]; | ||
18 | return value ? parseInt(value) : fallback; | ||
19 | }; | ||
20 | const listenHost = process.env['LISTEN_HOST'] || 'localhost'; | ||
21 | const listenPort = portNumberOrElse('LISTEN_PORT', 1313); | ||
22 | const apiHost = process.env['API_HOST'] || listenHost; | ||
23 | const apiPort = portNumberOrElse('API_PORT', 1312); | ||
24 | const publicHost = process.env['PUBLIC_HOST'] || listenHost; | ||
25 | const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort); | ||
26 | |||
27 | const resolveSources = sources => path.resolve(__dirname, 'src', sources); | ||
28 | const mainJsSources = resolveSources('main/js'); | ||
29 | const babelLoaderFilters = { | ||
30 | include: [mainJsSources], | ||
31 | }; | ||
32 | const babelPresets = [ | ||
33 | [ | ||
34 | '@babel/preset-env', | ||
35 | { | ||
36 | targets: 'defaults', | ||
37 | }, | ||
38 | ], | ||
39 | '@babel/preset-react', | ||
40 | ]; | ||
41 | const babelPlugins = [ | ||
42 | '@babel/plugin-transform-runtime', | ||
43 | ] | ||
44 | const magicCommentsLoader = { | ||
45 | loader: 'magic-comments-loader', | ||
46 | options: { | ||
47 | webpackChunkName: true, | ||
48 | } | ||
49 | }; | ||
50 | |||
51 | module.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 | }; | ||