aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <marussy@mit.bme.hu>2021-10-31 19:41:24 +0100
committerLibravatar GitHub <noreply@github.com>2021-10-31 19:41:24 +0100
commit7918d78948de7f349f9948837caf76f2a514c96c (patch)
tree0f7c24a9e0046dffd719d6a66be4a1f73b7fa4c7 /language-web/src/main
parentMerge pull request #7 from golej-marci/language-to-store (diff)
parentchore: bump dependency versions (diff)
downloadrefinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.gz
refinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.zst
refinery-7918d78948de7f349f9948837caf76f2a514c96c.zip
Merge pull request #8 from kris7t/cm6
Switch to CodeMirror 6 editor and WebSocket-based transport for Xtext
Diffstat (limited to 'language-web/src/main')
-rw-r--r--language-web/src/main/css/index.scss213
-rw-r--r--language-web/src/main/css/xtext/xtext-codemirror.css58
-rw-r--r--language-web/src/main/images/error_an.gifbin553 -> 0 bytes
-rw-r--r--language-web/src/main/images/info_an.gifbin101 -> 0 bytes
-rw-r--r--language-web/src/main/images/warning_an.gifbin522 -> 0 bytes
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java21
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java24
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java (renamed from language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java)13
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java73
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java16
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java107
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java53
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java196
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java44
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java8
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java14
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java26
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java180
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java11
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java79
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java72
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java81
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java57
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java4
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java15
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java23
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java89
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java68
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java33
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java26
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java35
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java9
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java133
-rw-r--r--language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java83
-rw-r--r--language-web/src/main/js/App.tsx11
-rw-r--r--language-web/src/main/js/RootStore.tsx4
-rw-r--r--language-web/src/main/js/editor/EditorArea.tsx170
-rw-r--r--language-web/src/main/js/editor/EditorButtons.tsx70
-rw-r--r--language-web/src/main/js/editor/EditorParent.ts200
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts358
-rw-r--r--language-web/src/main/js/editor/GenerateButton.tsx44
-rw-r--r--language-web/src/main/js/editor/decorationSetExtension.ts39
-rw-r--r--language-web/src/main/js/editor/editor.ts18
-rw-r--r--language-web/src/main/js/editor/findOccurrences.ts35
-rw-r--r--language-web/src/main/js/editor/semanticHighlighting.ts24
-rw-r--r--language-web/src/main/js/index.tsx30
-rw-r--r--language-web/src/main/js/language/folding.ts115
-rw-r--r--language-web/src/main/js/language/indentation.ts87
-rw-r--r--language-web/src/main/js/language/problem.grammar145
-rw-r--r--language-web/src/main/js/language/problemLanguageSupport.ts92
-rw-r--r--language-web/src/main/js/theme/ThemeStore.ts7
-rw-r--r--language-web/src/main/js/utils/ConditionVariable.ts64
-rw-r--r--language-web/src/main/js/utils/PendingTask.ts60
-rw-r--r--language-web/src/main/js/utils/Timer.ts33
-rw-r--r--language-web/src/main/js/utils/logger.ts (renamed from language-web/src/main/js/logging.tsx)2
-rw-r--r--language-web/src/main/js/xtext/CodeMirrorEditorContext.js111
-rw-r--r--language-web/src/main/js/xtext/ContentAssistService.ts177
-rw-r--r--language-web/src/main/js/xtext/HighlightingService.ts43
-rw-r--r--language-web/src/main/js/xtext/OccurrencesService.ts116
-rw-r--r--language-web/src/main/js/xtext/ServiceBuilder.js285
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts310
-rw-r--r--language-web/src/main/js/xtext/ValidationService.ts45
-rw-r--r--language-web/src/main/js/xtext/XtextClient.ts83
-rw-r--r--language-web/src/main/js/xtext/XtextWebSocketClient.ts341
-rw-r--r--language-web/src/main/js/xtext/compatibility.js63
-rw-r--r--language-web/src/main/js/xtext/services/ContentAssistService.js132
-rw-r--r--language-web/src/main/js/xtext/services/FormattingService.js52
-rw-r--r--language-web/src/main/js/xtext/services/HighlightingService.js33
-rw-r--r--language-web/src/main/js/xtext/services/HoverService.js59
-rw-r--r--language-web/src/main/js/xtext/services/LoadResourceService.js42
-rw-r--r--language-web/src/main/js/xtext/services/OccurrencesService.js39
-rw-r--r--language-web/src/main/js/xtext/services/SaveResourceService.js32
-rw-r--r--language-web/src/main/js/xtext/services/UpdateService.js159
-rw-r--r--language-web/src/main/js/xtext/services/ValidationService.js33
-rw-r--r--language-web/src/main/js/xtext/services/XtextService.js280
-rw-r--r--language-web/src/main/js/xtext/xtext-codemirror.d.ts43
-rw-r--r--language-web/src/main/js/xtext/xtext-codemirror.js473
-rw-r--r--language-web/src/main/js/xtext/xtextMessages.ts62
-rw-r--r--language-web/src/main/js/xtext/xtextServiceResults.ts239
79 files changed, 4010 insertions, 2714 deletions
diff --git a/language-web/src/main/css/index.scss b/language-web/src/main/css/index.scss
index 54f3a654..ad876aaf 100644
--- a/language-web/src/main/css/index.scss
+++ b/language-web/src/main/css/index.scss
@@ -1,13 +1,6 @@
1@use 'sass:map';
2@use '@fontsource/roboto/scss/mixins' as Roboto; 1@use '@fontsource/roboto/scss/mixins' as Roboto;
3@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono; 2@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono;
4 3
5@import 'codemirror/lib/codemirror';
6@import 'codemirror/addon/hint/show-hint';
7@import 'codemirror/theme/material-darker';
8
9@import './themes';
10
11$fontWeights: 300, 400, 500, 700; 4$fontWeights: 300, 400, 500, 700;
12@each $weight in $fontWeights { 5@each $weight in $fontWeights {
13 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight); 6 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight);
@@ -21,209 +14,3 @@ $monoFontWeights: 400, 700;
21} 14}
22@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable'); 15@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable');
23@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic); 16@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic);
24
25body {
26 font-family: 'Roboto', sans-serif;
27}
28
29.CodeMirror {
30 height: 100%;
31}
32
33.problem-fallback-editor {
34 display: block;
35 height: 100%;
36 width: 100%;
37 resize: none;
38 border: none;
39 outline: none;
40 padding: 4px 4px 4px 16px;
41 white-space: pre;
42 overflow-wrap: normal;
43 overflow: auto;
44}
45
46.CodeMirror, .CodeMirror-hints, .problem-fallback-editor {
47 font-size: 16px;
48 font-family: 'JetBrains MonoVariable', 'JetBrains Mono', monospace;
49 font-feature-settings: 'liga', 'calt';
50 font-weight: 400;
51 text-rendering: optimizeLegibility;
52 line-height: 1.35;
53 letter-spacing: 0;
54}
55
56@each $themeName, $theme in $themes {
57 .cm-s-problem-#{$themeName} {
58 &.CodeMirror {
59 background: map.get($theme, 'background');
60 color: map.get($theme, 'foreground');
61 }
62
63 &.problem-fallback-editor {
64 background: map.get($theme, 'background');
65 color: map.get($theme, 'foreground');
66 caret-color: map.get($theme, 'cursor');
67
68 &::selection {
69 background: map.get($theme, 'selection');
70 }
71 }
72
73 .CodeMirror-gutters {
74 background: map.get($theme, 'background');
75 border: none;
76 }
77
78 .CodeMirror-cursor {
79 border-left: 1px solid map.get($theme, 'cursor');
80 }
81
82 div.CodeMirror-selected,
83 &.CodeMirror-focused div.CodeMirror-selected,
84 .CodeMirror-line::selection,
85 .CodeMirror-line > span::selection,
86 .CodeMirror-line > span > span::selection {
87 background: map.get($theme, 'selection');
88 }
89
90 .CodeMirror-guttermarker,
91 .CodeMirror-guttermarker-subtle,
92 .CodeMirror-linenumber {
93 color: map.get($theme, 'lineNumber');
94 }
95
96 .CodeMirror-activeline-background {
97 background: map.get($theme, 'currentLine');
98 }
99
100 .CodeMirror-activeline-gutter {
101 background: map.get($theme, 'currentLine');
102
103 .CodeMirror-guttermarker,
104 .CodeMirror-guttermarker-subtle,
105 .CodeMirror-linenumber {
106 color: map.get($theme, 'foreground');
107 }
108 }
109
110 .cm-keyword {
111 color: map.get($theme, 'keyword');
112 }
113
114 .cm-number {
115 color: map.get($theme, 'number');
116 }
117
118 .cm-lparen, .cm-rparen {
119 color: map.get($theme, 'delimiter');
120 }
121
122 .cm-comment {
123 color: map.get($theme, 'comment');
124 font-style: italic;
125 }
126
127 .problem-predicate, .problem-class, .problem-reference, .problem-enum {
128 color: map.get($theme, 'predicate');
129 }
130
131 .problem-unique-node {
132 color: map.get($theme, 'uniqueNode');
133 }
134
135 .problem-variable {
136 color: map.get($theme, 'variable');
137 }
138 }
139}
140
141.CodeMirror-hints {
142 background: #333;
143 border: 0;
144 border-radius: 4px;
145 box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2),
146 0 5px 8px 0 rgba(0, 0, 0, 0.14),
147 0 1px 8px 0 rgba(0, 0, 0, 0.12);
148 padding: 0;
149}
150
151.CodeMirror-hint {
152 color: #fff;
153 border-radius: 0;
154}
155
156li.CodeMirror-hint-active {
157 background: rgba(128, 203, 196, 0.2);
158}
159
160.annotations-gutter {
161 width: 12px;
162}
163
164.xtext-annotation_error {
165 width: 12px;
166 height: 1em;
167 background-image: url('images/error_an.gif');
168 background-repeat: no-repeat;
169 background-position: bottom;
170}
171
172.xtext-annotation_warning {
173 width: 12px;
174 height: 1em;
175 background-image: url('images/warning_an.gif');
176 background-repeat: no-repeat;
177 background-position: bottom;
178}
179
180.xtext-annotation_info {
181 width: 12px;
182 height: 1em;
183 background-image: url('images/info_an.gif');
184 background-repeat: no-repeat;
185 background-position: bottom;
186}
187
188.xtext-marker_error {
189 z-index: 30;
190 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==");
191 background-repeat: repeat-x;
192 background-position: left bottom;
193}
194
195.xtext-marker_warning {
196 z-index: 20;
197 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=");
198 background-repeat: repeat-x;
199 background-position: left bottom;
200}
201
202.xtext-marker_info {
203 z-index: 10;
204 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAANklEQVQI12NkgIIVRx8tZGBg6GZccfRRKAMDgw8DA0M3AwPDIiYGBoZKBgaG7ghruSsMDAwpABH5CoqwzCoTAAAAAElFTkSuQmCC");
205 background-repeat: repeat-x;
206 background-position: left bottom;
207}
208
209.xtext-marker_read {
210 background: rgba(128, 203, 196, 0.2);
211 display: inline-block;
212}
213
214
215.xtext-marker_write {
216 background: rgba(255, 229, 100, 0.2);
217 display: inline-block;
218}
219
220.problem-abstract {
221 font-style: italic;
222}
223
224.problem-containment {
225 font-weight: 700;
226}
227.problem-new-node {
228 font-style: italic;
229}
diff --git a/language-web/src/main/css/xtext/xtext-codemirror.css b/language-web/src/main/css/xtext/xtext-codemirror.css
deleted file mode 100644
index 831b6daf..00000000
--- a/language-web/src/main/css/xtext/xtext-codemirror.css
+++ /dev/null
@@ -1,58 +0,0 @@
1.CodeMirror {
2 height: 100%;
3}
4
5.annotations-gutter {
6 width: 12px;
7 background: #f0f0f0;
8}
9
10.xtext-annotation_error {
11 width: 12px;
12 height: 12px;
13 background-image: url('images/error_an.gif');
14 background-repeat: no-repeat;
15}
16
17.xtext-annotation_warning {
18 width: 12px;
19 height: 12px;
20 background-image: url('images/warning_an.gif');
21 background-repeat: no-repeat;
22}
23
24.xtext-annotation_info {
25 width: 12px;
26 height: 12px;
27 background-image: url('images/info_an.gif');
28 background-repeat: no-repeat;
29}
30
31.xtext-marker_error {
32 z-index: 30;
33 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==");
34 background-repeat: repeat-x;
35 background-position: left bottom;
36}
37
38.xtext-marker_warning {
39 z-index: 20;
40 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=");
41 background-repeat: repeat-x;
42 background-position: left bottom;
43}
44
45.xtext-marker_info {
46 z-index: 10;
47 background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAABmJLR0QA/wD/AP+gvaeTAAAANklEQVQI12NkgIIVRx8tZGBg6GZccfRRKAMDgw8DA0M3AwPDIiYGBoZKBgaG7ghruSsMDAwpABH5CoqwzCoTAAAAAElFTkSuQmCC");
48 background-repeat: repeat-x;
49 background-position: left bottom;
50}
51
52.xtext-marker_read {
53 background-color: #ddd;
54}
55
56.xtext-marker_write {
57 background-color: yellow;
58}
diff --git a/language-web/src/main/images/error_an.gif b/language-web/src/main/images/error_an.gif
deleted file mode 100644
index e014ce90..00000000
--- a/language-web/src/main/images/error_an.gif
+++ /dev/null
Binary files differ
diff --git a/language-web/src/main/images/info_an.gif b/language-web/src/main/images/info_an.gif
deleted file mode 100644
index d62ad9dd..00000000
--- a/language-web/src/main/images/info_an.gif
+++ /dev/null
Binary files differ
diff --git a/language-web/src/main/images/warning_an.gif b/language-web/src/main/images/warning_an.gif
deleted file mode 100644
index 9ef66dd7..00000000
--- a/language-web/src/main/images/warning_an.gif
+++ /dev/null
Binary files differ
diff --git a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
index cf4c00fa..b13ae95d 100644
--- a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
+++ b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
@@ -1,8 +1,11 @@
1package tools.refinery.language.web; 1package tools.refinery.language.web;
2 2
3import java.io.IOException; 3import java.io.IOException;
4import java.time.Duration;
4import java.util.regex.Pattern; 5import java.util.regex.Pattern;
5 6
7import org.eclipse.jetty.http.HttpHeader;
8
6import jakarta.servlet.Filter; 9import jakarta.servlet.Filter;
7import jakarta.servlet.FilterChain; 10import jakarta.servlet.FilterChain;
8import jakarta.servlet.FilterConfig; 11import jakarta.servlet.FilterConfig;
@@ -13,16 +16,11 @@ import jakarta.servlet.http.HttpServletRequest;
13import jakarta.servlet.http.HttpServletResponse; 16import jakarta.servlet.http.HttpServletResponse;
14 17
15public class CacheControlFilter implements Filter { 18public class CacheControlFilter implements Filter {
16
17 private static final String CACHE_CONTROL_HEADER = "Cache-Control";
18
19 private static final String EXPIRES_HEADER = "Expires";
20
21 private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)"); 19 private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)");
22 20
23 private static final long EXPIRY = 31536000; 21 private static final Duration EXPIRY = Duration.ofDays(365);
24 22
25 private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY + ", immutable"; 23 private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable";
26 24
27 private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate"; 25 private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate";
28 26
@@ -36,11 +34,12 @@ public class CacheControlFilter implements Filter {
36 throws IOException, ServletException { 34 throws IOException, ServletException {
37 if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) { 35 if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) {
38 if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) { 36 if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) {
39 httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_CACHE_VALUE); 37 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE);
40 httpResponse.setDateHeader(EXPIRES_HEADER, System.currentTimeMillis() + EXPIRY * 1000L); 38 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(),
39 System.currentTimeMillis() + EXPIRY.toMillis());
41 } else { 40 } else {
42 httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_NO_CACHE_VALUE); 41 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE);
43 httpResponse.setDateHeader(EXPIRES_HEADER, 0); 42 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0);
44 } 43 }
45 } 44 }
46 chain.doFilter(request, response); 45 chain.doFilter(request, response);
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
index 799a9c64..ec55036f 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
@@ -3,9 +3,33 @@
3 */ 3 */
4package tools.refinery.language.web; 4package tools.refinery.language.web;
5 5
6import org.eclipse.xtext.web.server.XtextServiceDispatcher;
7import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
8import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
9import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
10
11import tools.refinery.language.web.occurrences.ProblemOccurrencesService;
12import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher;
13import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess;
14import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider;
6 15
7/** 16/**
8 * Use this class to register additional components to be used within the web application. 17 * Use this class to register additional components to be used within the web application.
9 */ 18 */
10public class ProblemWebModule extends AbstractProblemWebModule { 19public class ProblemWebModule extends AbstractProblemWebModule {
20 public Class<? extends IWebDocumentProvider> bindIWebDocumentProvider() {
21 return PushWebDocumentProvider.class;
22 }
23
24 public Class<? extends XtextWebDocumentAccess> bindXtextWebDocumentAccess() {
25 return PushWebDocumentAccess.class;
26 }
27
28 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() {
29 return PushServiceDispatcher.class;
30 }
31
32 public Class<? extends OccurrencesService> bindOccurrencesService() {
33 return ProblemOccurrencesService.class;
34 }
11} 35}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
index 49457002..df67b521 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
@@ -1,19 +1,13 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web; 1package tools.refinery.language.web;
5 2
6import org.eclipse.xtext.util.DisposableRegistry; 3import org.eclipse.xtext.util.DisposableRegistry;
7 4
8import jakarta.servlet.ServletException; 5import jakarta.servlet.ServletException;
9import tools.refinery.language.web.xtext.XtextServlet; 6import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
10 7
11/** 8public class ProblemWebSocketServlet extends XtextWebSocketServlet {
12 * Deploy this class into a servlet container to enable DSL-specific services.
13 */
14public class ProblemServlet extends XtextServlet {
15 9
16 private static final long serialVersionUID = -9204695886561362912L; 10 private static final long serialVersionUID = -7040955470384797008L;
17 11
18 private transient DisposableRegistry disposableRegistry; 12 private transient DisposableRegistry disposableRegistry;
19 13
@@ -32,5 +26,4 @@ public class ProblemServlet extends XtextServlet {
32 } 26 }
33 super.destroy(); 27 super.destroy();
34 } 28 }
35
36} 29}
diff --git a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
index c253422b..ffd903d0 100644
--- a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
+++ b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
@@ -9,6 +9,7 @@ import java.net.InetSocketAddress;
9import java.net.URI; 9import java.net.URI;
10import java.net.URISyntaxException; 10import java.net.URISyntaxException;
11import java.util.EnumSet; 11import java.util.EnumSet;
12import java.util.Optional;
12import java.util.Set; 13import java.util.Set;
13 14
14import org.eclipse.jetty.server.Server; 15import org.eclipse.jetty.server.Server;
@@ -17,29 +18,36 @@ import org.eclipse.jetty.servlet.DefaultServlet;
17import org.eclipse.jetty.servlet.ServletContextHandler; 18import org.eclipse.jetty.servlet.ServletContextHandler;
18import org.eclipse.jetty.servlet.ServletHolder; 19import org.eclipse.jetty.servlet.ServletHolder;
19import org.eclipse.jetty.util.resource.Resource; 20import org.eclipse.jetty.util.resource.Resource;
21import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
20import org.slf4j.Logger; 22import org.slf4j.Logger;
21import org.slf4j.LoggerFactory; 23import org.slf4j.LoggerFactory;
22 24
23import jakarta.servlet.DispatcherType; 25import jakarta.servlet.DispatcherType;
24import jakarta.servlet.SessionTrackingMode; 26import jakarta.servlet.SessionTrackingMode;
27import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
25 28
26public class ServerLauncher { 29public class ServerLauncher {
27 public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; 30 public static final String DEFAULT_LISTEN_ADDRESS = "localhost";
28 31
29 public static final int DEFAULT_LISTEN_PORT = 1312; 32 public static final int DEFAULT_LISTEN_PORT = 1312;
30 33
31 // Use this cookie name for load balancing. 34 public static final int DEFAULT_PUBLIC_PORT = 443;
32 public static final String SESSION_COOKIE_NAME = "JSESSIONID"; 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 = ";";
33 41
34 private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); 42 private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class);
35 43
36 private final Server server; 44 private final Server server;
37 45
38 public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource) { 46 public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional<String[]> allowedOrigins) {
39 server = new Server(bindAddress); 47 server = new Server(bindAddress);
40 var handler = new ServletContextHandler(); 48 var handler = new ServletContextHandler();
41 addSessionHandler(handler); 49 addSessionHandler(handler);
42 addProblemServlet(handler); 50 addProblemServlet(handler, allowedOrigins);
43 if (baseResource != null) { 51 if (baseResource != null) {
44 handler.setBaseResource(baseResource); 52 handler.setBaseResource(baseResource);
45 handler.setWelcomeFiles(new String[] { "index.html" }); 53 handler.setWelcomeFiles(new String[] { "index.html" });
@@ -52,12 +60,21 @@ public class ServerLauncher {
52 private void addSessionHandler(ServletContextHandler handler) { 60 private void addSessionHandler(ServletContextHandler handler) {
53 var sessionHandler = new SessionHandler(); 61 var sessionHandler = new SessionHandler();
54 sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); 62 sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE));
55 sessionHandler.setSessionCookie(SESSION_COOKIE_NAME);
56 handler.setSessionHandler(sessionHandler); 63 handler.setSessionHandler(sessionHandler);
57 } 64 }
58 65
59 private void addProblemServlet(ServletContextHandler handler) { 66 private void addProblemServlet(ServletContextHandler handler, Optional<String[]> allowedOrigins) {
60 handler.addServlet(ProblemServlet.class, "/xtext-service/*"); 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);
61 } 78 }
62 79
63 private void addDefaultServlet(ServletContextHandler handler) { 80 private void addDefaultServlet(ServletContextHandler handler) {
@@ -80,7 +97,8 @@ public class ServerLauncher {
80 try { 97 try {
81 var bindAddress = getBindAddress(); 98 var bindAddress = getBindAddress();
82 var baseResource = getBaseResource(); 99 var baseResource = getBaseResource();
83 var serverLauncher = new ServerLauncher(bindAddress, baseResource); 100 var allowedOrigins = getAllowedOrigins();
101 var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins);
84 serverLauncher.start(); 102 serverLauncher.start();
85 } catch (Exception exception) { 103 } catch (Exception exception) {
86 LOG.error("Fatal server error", exception); 104 LOG.error("Fatal server error", exception);
@@ -132,4 +150,43 @@ public class ServerLauncher {
132 // Fall back to just serving a 404. 150 // Fall back to just serving a 404.
133 return null; 151 return null;
134 } 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 }
135} 192}
diff --git a/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
new file mode 100644
index 00000000..d32bbb54
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
@@ -0,0 +1,16 @@
1package tools.refinery.language.web.occurrences;
2
3import org.eclipse.emf.ecore.EObject;
4import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
5
6import com.google.inject.Singleton;
7
8import tools.refinery.language.model.problem.NamedElement;
9
10@Singleton
11public class ProblemOccurrencesService extends OccurrencesService {
12 @Override
13 protected boolean filter(EObject element) {
14 return super.filter(element) && element instanceof NamedElement;
15 }
16}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java
deleted file mode 100644
index d0ba6a2d..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java
+++ /dev/null
@@ -1,107 +0,0 @@
1/**
2 * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 */
9package tools.refinery.language.web.xtext;
10
11import java.io.IOException;
12import java.net.URLDecoder;
13import java.nio.charset.Charset;
14import java.util.Collections;
15import java.util.Enumeration;
16import java.util.HashMap;
17import java.util.Map;
18import java.util.Set;
19
20import org.eclipse.xtext.web.server.IServiceContext;
21import org.eclipse.xtext.web.server.ISession;
22
23import com.google.common.io.CharStreams;
24
25import jakarta.servlet.http.HttpServletRequest;
26
27/**
28 * Provides the parameters and metadata of an {@link HttpServletRequest}.
29 */
30class HttpServiceContext implements IServiceContext {
31 private final HttpServletRequest request;
32
33 private final Map<String, String> parameters = new HashMap<>();
34
35 private HttpSessionWrapper sessionWrapper;
36
37 public HttpServiceContext(HttpServletRequest request) throws IOException {
38 this.request = request;
39 initializeParameters();
40 }
41
42 private void initializeParameters() throws IOException {
43 initializeUrlEncodedParameters();
44 initializeRequestParameters();
45 if (!parameters.containsKey(IServiceContext.SERVICE_TYPE)) {
46 String substring = null;
47 if (request.getPathInfo() != null) {
48 substring = request.getPathInfo().substring(1);
49 }
50 parameters.put(IServiceContext.SERVICE_TYPE, substring);
51 }
52 }
53
54 private void initializeUrlEncodedParameters() throws IOException {
55 String[] contentType = null;
56 if (request.getContentType() != null) {
57 contentType = request.getContentType().split(";(\\s*)");
58 }
59 if (contentType != null && "application/x-www-form-urlencoded".equals(contentType[0])) {
60 String charset = null;
61 if (contentType.length >= 2 && contentType[1].startsWith("charset=")) {
62 charset = (contentType[1]).substring("charset=".length());
63 } else {
64 charset = Charset.defaultCharset().toString();
65 }
66 String[] encodedParams = CharStreams.toString(request.getReader()).split("&");
67 for (String param : encodedParams) {
68 int nameEnd = param.indexOf("=");
69 if (nameEnd > 0) {
70 String key = param.substring(0, nameEnd);
71 String value = URLDecoder.decode(param.substring(nameEnd + 1), charset);
72 parameters.put(key, value);
73 }
74 }
75 }
76 }
77
78 private void initializeRequestParameters() {
79 Enumeration<String> paramNames = request.getParameterNames();
80 while (paramNames.hasMoreElements()) {
81 String name = paramNames.nextElement();
82 parameters.put(name, request.getParameter(name));
83 }
84 }
85
86 @Override
87 public Set<String> getParameterKeys() {
88 return Collections.unmodifiableSet(parameters.keySet());
89 }
90
91 @Override
92 public String getParameter(String key) {
93 return parameters.get(key);
94 }
95
96 @Override
97 public ISession getSession() {
98 if (sessionWrapper == null) {
99 sessionWrapper = new HttpSessionWrapper(request.getSession(true));
100 }
101 return sessionWrapper;
102 }
103
104 public HttpServletRequest getRequest() {
105 return request;
106 }
107}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java
deleted file mode 100644
index 8a5e19ba..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java
+++ /dev/null
@@ -1,53 +0,0 @@
1/**
2 * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 */
9package tools.refinery.language.web.xtext;
10
11import org.eclipse.xtext.web.server.ISession;
12import org.eclipse.xtext.xbase.lib.Functions.Function0;
13
14import jakarta.servlet.http.HttpSession;
15
16/**
17 * Provides access to the information stored in a {@link HttpSession}.
18 */
19record HttpSessionWrapper(HttpSession session) implements ISession {
20 @SuppressWarnings("unchecked")
21 @Override
22 public <T> T get(Object key) {
23 return (T) session.getAttribute(key.toString());
24 }
25
26 @Override
27 public <T> T get(Object key, Function0<? extends T> factory) {
28 synchronized (session) {
29 T sessionValue = get(key);
30 if (sessionValue != null) {
31 return sessionValue;
32 } else {
33 T factoryValue = factory.apply();
34 put(key, factoryValue);
35 return factoryValue;
36 }
37 }
38 }
39
40 @Override
41 public void put(Object key, Object value) {
42 session.setAttribute(key.toString(), value);
43 }
44
45 @Override
46 public void remove(Object key) {
47 session.removeAttribute(key.toString());
48 }
49
50 public HttpSession getSession() {
51 return session;
52 }
53}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java
deleted file mode 100644
index f39bec12..00000000
--- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java
+++ /dev/null
@@ -1,196 +0,0 @@
1/**
2 * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 */
9package tools.refinery.language.web.xtext;
10
11import java.io.IOException;
12import java.util.Set;
13
14import org.eclipse.emf.common.util.URI;
15import org.eclipse.xtext.resource.IResourceServiceProvider;
16import org.eclipse.xtext.web.server.IServiceContext;
17import org.eclipse.xtext.web.server.IServiceResult;
18import org.eclipse.xtext.web.server.IUnwrappableServiceResult;
19import org.eclipse.xtext.web.server.InvalidRequestException;
20import org.eclipse.xtext.web.server.XtextServiceDispatcher;
21import org.slf4j.Logger;
22import org.slf4j.LoggerFactory;
23
24import com.google.common.base.Objects;
25import com.google.common.base.Strings;
26import com.google.gson.Gson;
27import com.google.inject.Injector;
28
29import jakarta.servlet.ServletException;
30import jakarta.servlet.http.HttpServlet;
31import jakarta.servlet.http.HttpServletRequest;
32import jakarta.servlet.http.HttpServletResponse;
33
34/**
35 * An HTTP servlet for publishing the Xtext services. Include this into your web
36 * server by creating a subclass that executes the standalone setups of your
37 * languages in its {@link #init()} method:
38 *
39 * <pre>
40 * &#64;WebServlet(name = "Xtext Services", urlPatterns = "/xtext-service/*")
41 * class MyXtextServlet extends XtextServlet {
42 * override init() {
43 * super.init();
44 * MyDslWebSetup.doSetup();
45 * }
46 * }
47 * </pre>
48 *
49 * Use the {@code WebServlet} annotation to register your servlet. The default
50 * URL pattern for Xtext services is {@code "/xtext-service/*"}.
51 */
52public class XtextServlet extends HttpServlet {
53
54 private static final long serialVersionUID = 7784324070547781918L;
55
56 private static final IResourceServiceProvider.Registry SERVICE_PROVIDER_REGISTRY = IResourceServiceProvider.Registry.INSTANCE;
57
58 private static final String ENCODING = "UTF-8";
59
60 private static final String INVALID_REQUEST_MESSAGE = "Invalid request ({}): {}";
61
62 private final transient Logger log = LoggerFactory.getLogger(this.getClass());
63
64 private final transient Gson gson = new Gson();
65
66 @Override
67 protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
68 try {
69 super.service(req, resp);
70 } catch (InvalidRequestException.ResourceNotFoundException exception) {
71 log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage());
72 resp.sendError(HttpServletResponse.SC_NOT_FOUND, exception.getMessage());
73 } catch (InvalidRequestException.InvalidDocumentStateException exception) {
74 log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage());
75 resp.sendError(HttpServletResponse.SC_CONFLICT, exception.getMessage());
76 } catch (InvalidRequestException.PermissionDeniedException exception) {
77 log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage());
78 resp.sendError(HttpServletResponse.SC_FORBIDDEN, exception.getMessage());
79 } catch (InvalidRequestException exception) {
80 log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage());
81 resp.sendError(HttpServletResponse.SC_BAD_REQUEST, exception.getMessage());
82 }
83 }
84
85 @Override
86 protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
87 XtextServiceDispatcher.ServiceDescriptor service = getService(req);
88 if (!service.isHasConflict() && (service.isHasSideEffects() || hasTextInput(service))) {
89 super.doGet(req, resp);
90 } else {
91 doService(service, resp);
92 }
93 }
94
95 @Override
96 protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
97 XtextServiceDispatcher.ServiceDescriptor service = getService(req);
98 String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE);
99 if (!service.isHasConflict() && !Objects.equal(type, "update")) {
100 super.doPut(req, resp);
101 } else {
102 doService(service, resp);
103 }
104 }
105
106 @Override
107 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
108 XtextServiceDispatcher.ServiceDescriptor service = getService(req);
109 String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE);
110 if (!service.isHasConflict()
111 && (!service.isHasSideEffects() && !hasTextInput(service) || Objects.equal(type, "update"))) {
112 super.doPost(req, resp);
113 } else {
114 doService(service, resp);
115 }
116 }
117
118 protected boolean hasTextInput(XtextServiceDispatcher.ServiceDescriptor service) {
119 Set<String> parameterKeys = service.getContext().getParameterKeys();
120 return parameterKeys.contains("fullText") || parameterKeys.contains("deltaText");
121 }
122
123 /**
124 * Retrieve the service metadata for the given request. This involves resolving
125 * the Guice injector for the respective language, querying the
126 * {@link XtextServiceDispatcher}, and checking the permission to invoke the
127 * service.
128 */
129 protected XtextServiceDispatcher.ServiceDescriptor getService(HttpServletRequest request) throws IOException {
130 HttpServiceContext serviceContext = new HttpServiceContext(request);
131 Injector injector = getInjector(serviceContext);
132 XtextServiceDispatcher serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
133 return serviceDispatcher.getService(serviceContext);
134 }
135
136 /**
137 * Invoke the service function of the given service descriptor and write its
138 * result to the servlet response in Json format. An exception is made for
139 * {@link IUnwrappableServiceResult}: here the document itself is written into
140 * the response instead of wrapping it into a Json object.
141 */
142 protected void doService(XtextServiceDispatcher.ServiceDescriptor service, HttpServletResponse response)
143 throws IOException {
144 IServiceResult result = service.getService().apply();
145 response.setStatus(HttpServletResponse.SC_OK);
146 response.setCharacterEncoding(ENCODING);
147 response.setHeader("Cache-Control", "no-cache");
148 if (result instanceof IUnwrappableServiceResult unwrapResult && unwrapResult.getContent() != null) {
149 String contentType = null;
150 if (unwrapResult.getContentType() != null) {
151 contentType = unwrapResult.getContentType();
152 } else {
153 contentType = "text/plain";
154 }
155 response.setContentType(contentType);
156 response.getWriter().write(unwrapResult.getContent());
157 } else {
158 response.setContentType("text/x-json");
159 gson.toJson(result, response.getWriter());
160 }
161 }
162
163 /**
164 * Resolve the Guice injector for the language associated with the given
165 * context.
166 */
167 protected Injector getInjector(HttpServiceContext serviceContext)
168 throws InvalidRequestException.UnknownLanguageException {
169 IResourceServiceProvider resourceServiceProvider = null;
170 String parameter = serviceContext.getParameter("resource");
171 if (parameter == null) {
172 parameter = "";
173 }
174 URI emfURI = URI.createURI(parameter);
175 String contentType = serviceContext.getParameter("contentType");
176 if (Strings.isNullOrEmpty(contentType)) {
177 resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI);
178 if (resourceServiceProvider == null) {
179 if (emfURI.toString().isEmpty()) {
180 throw new InvalidRequestException.UnknownLanguageException(
181 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
182 } else {
183 throw new InvalidRequestException.UnknownLanguageException(
184 "Unable to identify the Xtext language for resource " + emfURI + ".");
185 }
186 }
187 } else {
188 resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI, contentType);
189 if (resourceServiceProvider == null) {
190 throw new InvalidRequestException.UnknownLanguageException(
191 "Unable to identify the Xtext language for contentType " + contentType + ".");
192 }
193 }
194 return resourceServiceProvider.get(Injector.class);
195 }
196} \ No newline at end of file
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
new file mode 100644
index 00000000..fe510f51
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
@@ -0,0 +1,44 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6
7public class PongResult implements IServiceResult {
8 private String pong;
9
10 public PongResult(String pong) {
11 super();
12 this.pong = pong;
13 }
14
15 public String getPong() {
16 return pong;
17 }
18
19 public void setPong(String pong) {
20 this.pong = pong;
21 }
22
23 @Override
24 public int hashCode() {
25 return Objects.hash(pong);
26 }
27
28 @Override
29 public boolean equals(Object obj) {
30 if (this == obj)
31 return true;
32 if (obj == null)
33 return false;
34 if (getClass() != obj.getClass())
35 return false;
36 PongResult other = (PongResult) obj;
37 return Objects.equals(pong, other.pong);
38 }
39
40 @Override
41 public String toString() {
42 return "PongResult [pong=" + pong + "]";
43 }
44}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
new file mode 100644
index 00000000..2a85afe3
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
@@ -0,0 +1,8 @@
1package tools.refinery.language.web.xtext.server;
2
3import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
4
5@FunctionalInterface
6public interface ResponseHandler {
7 void onResponse(XtextWebResponse response) throws ResponseHandlerException;
8}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
new file mode 100644
index 00000000..34fcb546
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
@@ -0,0 +1,14 @@
1package tools.refinery.language.web.xtext.server;
2
3public class ResponseHandlerException extends Exception {
4
5 private static final long serialVersionUID = 3589866922420268164L;
6
7 public ResponseHandlerException(String message, Throwable cause) {
8 super(message, cause);
9 }
10
11 public ResponseHandlerException(String message) {
12 super(message);
13 }
14}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
new file mode 100644
index 00000000..78e00a9e
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
@@ -0,0 +1,26 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Set;
4
5import org.eclipse.xtext.web.server.IServiceContext;
6import org.eclipse.xtext.web.server.ISession;
7
8import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
9
10public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber)
11 implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return delegate.getParameterKeys();
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return delegate.getParameter(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return delegate.getSession();
25 }
26}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
new file mode 100644
index 00000000..0b417b06
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
@@ -0,0 +1,180 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.lang.ref.WeakReference;
4import java.util.ArrayList;
5import java.util.HashMap;
6import java.util.List;
7import java.util.Map;
8
9import org.eclipse.emf.common.util.URI;
10import org.eclipse.xtext.resource.IResourceServiceProvider;
11import org.eclipse.xtext.util.IDisposable;
12import org.eclipse.xtext.web.server.IServiceContext;
13import org.eclipse.xtext.web.server.IServiceResult;
14import org.eclipse.xtext.web.server.ISession;
15import org.eclipse.xtext.web.server.InvalidRequestException;
16import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException;
17import org.eclipse.xtext.web.server.XtextServiceDispatcher;
18import org.slf4j.Logger;
19import org.slf4j.LoggerFactory;
20
21import com.google.common.base.Strings;
22import com.google.inject.Injector;
23
24import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind;
25import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse;
26import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
27import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage;
28import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
29import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
30import tools.refinery.language.web.xtext.server.push.PushWebDocument;
31import tools.refinery.language.web.xtext.servlet.SimpleServiceContext;
32
33public class TransactionExecutor implements IDisposable, PrecomputationListener {
34 private static final Logger LOG = LoggerFactory.getLogger(TransactionExecutor.class);
35
36 private final ISession session;
37
38 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
39
40 private final Map<String, WeakReference<PushWebDocument>> subscriptions = new HashMap<>();
41
42 private ResponseHandler responseHandler;
43
44 private Object callPendingLock = new Object();
45
46 private boolean callPending;
47
48 private List<XtextWebPushMessage> pendingPushMessages = new ArrayList<>();
49
50 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
51 this.session = session;
52 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
53 }
54
55 public void setResponseHandler(ResponseHandler responseHandler) {
56 this.responseHandler = responseHandler;
57 }
58
59 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException {
60 var serviceContext = new SimpleServiceContext(session, request.getRequestData());
61 var ping = serviceContext.getParameter("ping");
62 if (ping != null) {
63 responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping)));
64 return;
65 }
66 synchronized (callPendingLock) {
67 if (callPending) {
68 LOG.error("Reentrant request detected");
69 }
70 if (!pendingPushMessages.isEmpty()) {
71 LOG.error("{} push messages got stuck without a pending request", pendingPushMessages.size());
72 }
73 callPending = true;
74 }
75 try {
76 var injector = getInjector(serviceContext);
77 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
78 var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this));
79 var serviceResult = service.getService().apply();
80 responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult));
81 } catch (InvalidRequestException e) {
82 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e));
83 } catch (RuntimeException e) {
84 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e));
85 } finally {
86 synchronized (callPendingLock) {
87 for (var message : pendingPushMessages) {
88 try {
89 responseHandler.onResponse(message);
90 } catch (ResponseHandlerException | RuntimeException e) {
91 LOG.error("Error while flushing push message", e);
92 }
93 }
94 pendingPushMessages.clear();
95 callPending = false;
96 }
97 }
98 }
99
100 @Override
101 public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName,
102 IServiceResult serviceResult) throws ResponseHandlerException {
103 var message = new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult);
104 synchronized (callPendingLock) {
105 // If we're currently responding to a call we must delay any push messages until
106 // the reply is sent, because push messages relating to the new state id must be
107 // sent after the response with the new state id so that the client knows about
108 // the new state when it receives the push message.
109 if (callPending) {
110 pendingPushMessages.add(message);
111 } else {
112 responseHandler.onResponse(message);
113 }
114 }
115 }
116
117 @Override
118 public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
119 PushWebDocument previousDocument = null;
120 var previousSubscription = subscriptions.get(resourceId);
121 if (previousSubscription != null) {
122 previousDocument = previousSubscription.get();
123 }
124 if (previousDocument == document) {
125 return;
126 }
127 if (previousDocument != null) {
128 previousDocument.removePrecomputationListener(this);
129 }
130 subscriptions.put(resourceId, new WeakReference<>(document));
131 }
132
133 /**
134 * Get the injector to satisfy the request in the {@code serviceContext}.
135 *
136 * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}.
137 *
138 * @param serviceContext the Xtext service context of the request
139 * @return the injector for the Xtext language in the request
140 * @throws UnknownLanguageException if the Xtext language cannot be determined
141 */
142 protected Injector getInjector(IServiceContext context) {
143 IResourceServiceProvider resourceServiceProvider = null;
144 var resourceName = context.getParameter("resource");
145 if (resourceName == null) {
146 resourceName = "";
147 }
148 var emfURI = URI.createURI(resourceName);
149 var contentType = context.getParameter("contentType");
150 if (Strings.isNullOrEmpty(contentType)) {
151 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI);
152 if (resourceServiceProvider == null) {
153 if (emfURI.toString().isEmpty()) {
154 throw new UnknownLanguageException(
155 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
156 } else {
157 throw new UnknownLanguageException(
158 "Unable to identify the Xtext language for resource " + emfURI + ".");
159 }
160 }
161 } else {
162 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType);
163 if (resourceServiceProvider == null) {
164 throw new UnknownLanguageException(
165 "Unable to identify the Xtext language for contentType " + contentType + ".");
166 }
167 }
168 return resourceServiceProvider.get(Injector.class);
169 }
170
171 @Override
172 public void dispose() {
173 for (var subscription : subscriptions.values()) {
174 var document = subscription.get();
175 if (document != null) {
176 document.removePrecomputationListener(this);
177 }
178 }
179 }
180}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
new file mode 100644
index 00000000..f74bae74
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
@@ -0,0 +1,11 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import com.google.gson.annotations.SerializedName;
4
5public enum XtextWebErrorKind {
6 @SerializedName("request")
7 REQUEST_ERROR,
8
9 @SerializedName("server")
10 SERVER_ERROR,
11}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
new file mode 100644
index 00000000..01d78c31
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
@@ -0,0 +1,79 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebErrorResponse implements XtextWebResponse {
8 private String id;
9
10 @SerializedName("error")
11 private XtextWebErrorKind errorKind;
12
13 @SerializedName("message")
14 private String errorMessage;
15
16 public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) {
17 super();
18 this.id = id;
19 this.errorKind = errorKind;
20 this.errorMessage = errorMessage;
21 }
22
23 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind,
24 String errorMessage) {
25 this(request.getId(), errorKind, errorMessage);
26 }
27
28 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) {
29 this(request, errorKind, t.getMessage());
30 }
31
32 public String getId() {
33 return id;
34 }
35
36 public void setId(String id) {
37 this.id = id;
38 }
39
40 public XtextWebErrorKind getErrorKind() {
41 return errorKind;
42 }
43
44 public void setErrorKind(XtextWebErrorKind errorKind) {
45 this.errorKind = errorKind;
46 }
47
48 public String getErrorMessage() {
49 return errorMessage;
50 }
51
52 public void setErrorMessage(String errorMessage) {
53 this.errorMessage = errorMessage;
54 }
55
56 @Override
57 public int hashCode() {
58 return Objects.hash(errorKind, errorMessage, id);
59 }
60
61 @Override
62 public boolean equals(Object obj) {
63 if (this == obj)
64 return true;
65 if (obj == null)
66 return false;
67 if (getClass() != obj.getClass())
68 return false;
69 XtextWebErrorResponse other = (XtextWebErrorResponse) obj;
70 return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage)
71 && Objects.equals(id, other.id);
72 }
73
74 @Override
75 public String toString() {
76 return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage
77 + "]";
78 }
79}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
new file mode 100644
index 00000000..8af27247
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
@@ -0,0 +1,72 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6import org.eclipse.xtext.web.server.IUnwrappableServiceResult;
7
8import com.google.gson.annotations.SerializedName;
9
10public final class XtextWebOkResponse implements XtextWebResponse {
11 private String id;
12
13 @SerializedName("response")
14 private Object responseData;
15
16 public XtextWebOkResponse(String id, Object responseData) {
17 super();
18 this.id = id;
19 this.responseData = responseData;
20 }
21
22 public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) {
23 this(request.getId(), maybeUnwrap(result));
24 }
25
26 public String getId() {
27 return id;
28 }
29
30 public void setId(String id) {
31 this.id = id;
32 }
33
34 public Object getResponseData() {
35 return responseData;
36 }
37
38 public void setResponseData(Object responseData) {
39 this.responseData = responseData;
40 }
41
42 @Override
43 public int hashCode() {
44 return Objects.hash(id, responseData);
45 }
46
47 @Override
48 public boolean equals(Object obj) {
49 if (this == obj)
50 return true;
51 if (obj == null)
52 return false;
53 if (getClass() != obj.getClass())
54 return false;
55 XtextWebOkResponse other = (XtextWebOkResponse) obj;
56 return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData);
57 }
58
59 @Override
60 public String toString() {
61 return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]";
62 }
63
64 private static Object maybeUnwrap(IServiceResult result) {
65 if (result instanceof IUnwrappableServiceResult unwrappableServiceResult
66 && unwrappableServiceResult.getContent() != null) {
67 return unwrappableServiceResult.getContent();
68 } else {
69 return result;
70 }
71 }
72}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
new file mode 100644
index 00000000..c9432e1c
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
@@ -0,0 +1,81 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebPushMessage implements XtextWebResponse {
8 @SerializedName("resource")
9 private String resourceId;
10
11 private String stateId;
12
13 private String service;
14
15 @SerializedName("push")
16 private Object pushData;
17
18 public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) {
19 super();
20 this.resourceId = resourceId;
21 this.stateId = stateId;
22 this.service = service;
23 this.pushData = pushData;
24 }
25
26 public String getResourceId() {
27 return resourceId;
28 }
29
30 public void setResourceId(String resourceId) {
31 this.resourceId = resourceId;
32 }
33
34 public String getStateId() {
35 return stateId;
36 }
37
38 public void setStateId(String stateId) {
39 this.stateId = stateId;
40 }
41
42 public String getService() {
43 return service;
44 }
45
46 public void setService(String service) {
47 this.service = service;
48 }
49
50 public Object getPushData() {
51 return pushData;
52 }
53
54 public void setPushData(Object pushData) {
55 this.pushData = pushData;
56 }
57
58 @Override
59 public int hashCode() {
60 return Objects.hash(pushData, resourceId, service, stateId);
61 }
62
63 @Override
64 public boolean equals(Object obj) {
65 if (this == obj)
66 return true;
67 if (obj == null)
68 return false;
69 if (getClass() != obj.getClass())
70 return false;
71 XtextWebPushMessage other = (XtextWebPushMessage) obj;
72 return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId)
73 && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId);
74 }
75
76 @Override
77 public String toString() {
78 return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service
79 + ", pushData=" + pushData + "]";
80 }
81}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
new file mode 100644
index 00000000..959749f8
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
@@ -0,0 +1,57 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Map;
4import java.util.Objects;
5
6import com.google.gson.annotations.SerializedName;
7
8public class XtextWebRequest {
9 private String id;
10
11 @SerializedName("request")
12 private Map<String, String> requestData;
13
14 public XtextWebRequest(String id, Map<String, String> requestData) {
15 super();
16 this.id = id;
17 this.requestData = requestData;
18 }
19
20 public String getId() {
21 return id;
22 }
23
24 public void setId(String id) {
25 this.id = id;
26 }
27
28 public Map<String, String> getRequestData() {
29 return requestData;
30 }
31
32 public void setRequestData(Map<String, String> requestData) {
33 this.requestData = requestData;
34 }
35
36 @Override
37 public int hashCode() {
38 return Objects.hash(id, requestData);
39 }
40
41 @Override
42 public boolean equals(Object obj) {
43 if (this == obj)
44 return true;
45 if (obj == null)
46 return false;
47 if (getClass() != obj.getClass())
48 return false;
49 XtextWebRequest other = (XtextWebRequest) obj;
50 return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData);
51 }
52
53 @Override
54 public String toString() {
55 return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]";
56 }
57}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
new file mode 100644
index 00000000..3bd13047
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
@@ -0,0 +1,4 @@
1package tools.refinery.language.web.xtext.server.message;
2
3public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage {
4}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
new file mode 100644
index 00000000..79a284db
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
@@ -0,0 +1,15 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceResult;
4
5import tools.refinery.language.web.xtext.server.ResponseHandlerException;
6
7@FunctionalInterface
8public interface PrecomputationListener {
9 void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult)
10 throws ResponseHandlerException;
11
12 default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
13 // Nothing to handle by default.
14 }
15}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
new file mode 100644
index 00000000..c7b8108d
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
@@ -0,0 +1,23 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.XtextServiceDispatcher;
5import org.eclipse.xtext.web.server.model.XtextWebDocument;
6
7import com.google.inject.Singleton;
8
9import tools.refinery.language.web.xtext.server.SubscribingServiceContext;
10
11@Singleton
12public class PushServiceDispatcher extends XtextServiceDispatcher {
13
14 @Override
15 protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) {
16 var document = super.getFullTextDocument(fullText, resourceId, context);
17 if (document instanceof PushWebDocument pushWebDocument
18 && context instanceof SubscribingServiceContext subscribingContext) {
19 pushWebDocument.addPrecomputationListener(subscribingContext.subscriber());
20 }
21 return document;
22 }
23}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
new file mode 100644
index 00000000..906b9e30
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
@@ -0,0 +1,89 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import java.util.ArrayList;
4import java.util.HashMap;
5import java.util.List;
6import java.util.Map;
7
8import org.eclipse.xtext.util.CancelIndicator;
9import org.eclipse.xtext.web.server.IServiceResult;
10import org.eclipse.xtext.web.server.model.AbstractCachedService;
11import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
12import org.eclipse.xtext.web.server.model.XtextWebDocument;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import com.google.common.collect.ImmutableList;
17
18import tools.refinery.language.web.xtext.server.ResponseHandlerException;
19
20public class PushWebDocument extends XtextWebDocument {
21 private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class);
22
23 private final List<PrecomputationListener> precomputationListeners = new ArrayList<>();
24
25 private final Map<Class<?>, IServiceResult> precomputedServices = new HashMap<>();
26
27 public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) {
28 super(resourceId, synchronizer);
29 if (resourceId == null) {
30 throw new IllegalArgumentException("resourceId must not be null");
31 }
32 }
33
34 public boolean addPrecomputationListener(PrecomputationListener listener) {
35 synchronized (precomputationListeners) {
36 if (precomputationListeners.contains(listener)) {
37 return false;
38 }
39 precomputationListeners.add(listener);
40 listener.onSubscribeToPrecomputationEvents(getResourceId(), this);
41 return true;
42 }
43 }
44
45 public boolean removePrecomputationListener(PrecomputationListener listener) {
46 synchronized (precomputationListeners) {
47 return precomputationListeners.remove(listener);
48 }
49 }
50
51 public <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, String serviceName,
52 CancelIndicator cancelIndicator, boolean logCacheMiss) {
53 var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss);
54 if (result == null) {
55 LOG.error("{} service returned null result", serviceName);
56 return;
57 }
58 var serviceClass = service.getClass();
59 var previousResult = precomputedServices.get(serviceClass);
60 if (previousResult != null && previousResult.equals(result)) {
61 return;
62 }
63 precomputedServices.put(serviceClass, result);
64 notifyPrecomputationListeners(serviceName, result);
65 }
66
67 private <T extends IServiceResult> void notifyPrecomputationListeners(String serviceName, T result) {
68 var resourceId = getResourceId();
69 var stateId = getStateId();
70 List<PrecomputationListener> copyOfListeners;
71 synchronized (precomputationListeners) {
72 copyOfListeners = ImmutableList.copyOf(precomputationListeners);
73 }
74 var toRemove = new ArrayList<PrecomputationListener>();
75 for (var listener : copyOfListeners) {
76 try {
77 listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result);
78 } catch (ResponseHandlerException e) {
79 LOG.error("Delivering precomputation push message failed", e);
80 toRemove.add(listener);
81 }
82 }
83 if (!toRemove.isEmpty()) {
84 synchronized (precomputationListeners) {
85 precomputationListeners.removeAll(toRemove);
86 }
87 }
88 }
89}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
new file mode 100644
index 00000000..b3666a86
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
@@ -0,0 +1,68 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.service.OperationCanceledManager;
4import org.eclipse.xtext.util.CancelIndicator;
5import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork;
6import org.eclipse.xtext.web.server.IServiceResult;
7import org.eclipse.xtext.web.server.model.AbstractCachedService;
8import org.eclipse.xtext.web.server.model.IXtextWebDocument;
9import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry;
10import org.eclipse.xtext.web.server.model.XtextWebDocument;
11import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
12import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService;
13import org.eclipse.xtext.web.server.validation.ValidationService;
14
15import com.google.inject.Inject;
16
17public class PushWebDocumentAccess extends XtextWebDocumentAccess {
18
19 @Inject
20 private PrecomputedServiceRegistry preComputedServiceRegistry;
21
22 @Inject
23 private OperationCanceledManager operationCanceledManager;
24
25 private PushWebDocument pushDocument;
26
27 @Override
28 protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) {
29 super.init(document, requiredStateId, skipAsyncWork);
30 if (document instanceof PushWebDocument newPushDocument) {
31 pushDocument = newPushDocument;
32 }
33 }
34
35 @Override
36 protected void performPrecomputation(CancelIndicator cancelIndicator) {
37 if (pushDocument == null) {
38 super.performPrecomputation(cancelIndicator);
39 return;
40 }
41 for (AbstractCachedService<? extends IServiceResult> service : preComputedServiceRegistry
42 .getPrecomputedServices()) {
43 operationCanceledManager.checkCanceled(cancelIndicator);
44 precomputeServiceResult(service, false);
45 }
46 }
47
48 protected <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, boolean logCacheMiss) {
49 var serviceName = getPrecomputedServiceName(service);
50 readOnly(new CancelableUnitOfWork<Void, IXtextWebDocument>() {
51 @Override
52 public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception {
53 pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss);
54 return null;
55 }
56 });
57 }
58
59 protected String getPrecomputedServiceName(AbstractCachedService<? extends IServiceResult> service) {
60 if (service instanceof ValidationService) {
61 return "validate";
62 }
63 if (service instanceof HighlightingService) {
64 return "highlight";
65 }
66 throw new IllegalArgumentException("Unknown precomputed service: " + service);
67 }
68}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
new file mode 100644
index 00000000..fc45f74a
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
@@ -0,0 +1,33 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
5import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
6import org.eclipse.xtext.web.server.model.XtextWebDocument;
7
8import com.google.inject.Inject;
9import com.google.inject.Provider;
10import com.google.inject.Singleton;
11
12/**
13 * Based on
14 * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}.
15 *
16 * @author Kristóf Marussy
17 */
18@Singleton
19public class PushWebDocumentProvider implements IWebDocumentProvider {
20 @Inject
21 private Provider<DocumentSynchronizer> synchronizerProvider;
22
23 @Override
24 public XtextWebDocument get(String resourceId, IServiceContext serviceContext) {
25 if (resourceId == null) {
26 return new XtextWebDocument(resourceId, synchronizerProvider.get());
27 } else {
28 // We only need to send push messages if a resourceId is specified.
29 return new PushWebDocument(resourceId,
30 serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get()));
31 }
32 }
33}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
new file mode 100644
index 00000000..43e37160
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
@@ -0,0 +1,26 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.Map;
4import java.util.Set;
5
6import org.eclipse.xtext.web.server.IServiceContext;
7import org.eclipse.xtext.web.server.ISession;
8
9import com.google.common.collect.ImmutableSet;
10
11public record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return ImmutableSet.copyOf(parameters.keySet());
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return parameters.get(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return session;
25 }
26}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
new file mode 100644
index 00000000..09c055a2
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
@@ -0,0 +1,35 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.HashMap;
4import java.util.Map;
5
6import org.eclipse.xtext.web.server.ISession;
7import org.eclipse.xtext.xbase.lib.Functions.Function0;
8
9public class SimpleSession implements ISession {
10 private Map<Object, Object> map = new HashMap<>();
11
12 @Override
13 public <T> T get(Object key) {
14 @SuppressWarnings("unchecked")
15 var value = (T) map.get(key);
16 return value;
17 }
18
19 @Override
20 public <T> T get(Object key, Function0<? extends T> factory) {
21 @SuppressWarnings("unchecked")
22 var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply());
23 return value;
24 }
25
26 @Override
27 public void put(Object key, Object value) {
28 map.put(key, value);
29 }
30
31 @Override
32 public void remove(Object key) {
33 map.remove(key);
34 }
35}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
new file mode 100644
index 00000000..0cd229e8
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
@@ -0,0 +1,9 @@
1package tools.refinery.language.web.xtext.servlet;
2
3public final class XtextStatusCode {
4 public static final int INVALID_JSON = 4007;
5
6 private XtextStatusCode() {
7 throw new IllegalStateException("This is a static utility class and should not be instantiated directly");
8 }
9}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
new file mode 100644
index 00000000..fd41f213
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
@@ -0,0 +1,133 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.io.Reader;
5
6import org.eclipse.jetty.websocket.api.Session;
7import org.eclipse.jetty.websocket.api.StatusCode;
8import org.eclipse.jetty.websocket.api.WriteCallback;
9import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
10import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
13import org.eclipse.jetty.websocket.api.annotations.WebSocket;
14import org.eclipse.xtext.resource.IResourceServiceProvider;
15import org.eclipse.xtext.web.server.ISession;
16import org.slf4j.Logger;
17import org.slf4j.LoggerFactory;
18
19import com.google.gson.Gson;
20import com.google.gson.JsonIOException;
21import com.google.gson.JsonParseException;
22
23import tools.refinery.language.web.xtext.server.ResponseHandler;
24import tools.refinery.language.web.xtext.server.ResponseHandlerException;
25import tools.refinery.language.web.xtext.server.TransactionExecutor;
26import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
27import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
28
29@WebSocket
30public class XtextWebSocket implements WriteCallback, ResponseHandler {
31 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
32
33 private final Gson gson = new Gson();
34
35 private final TransactionExecutor executor;
36
37 private Session webSocketSession;
38
39 public XtextWebSocket(TransactionExecutor executor) {
40 this.executor = executor;
41 executor.setResponseHandler(this);
42 }
43
44 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
45 this(new TransactionExecutor(session, resourceServiceProviderRegistry));
46 }
47
48 @OnWebSocketConnect
49 public void onConnect(Session webSocketSession) {
50 if (this.webSocketSession != null) {
51 LOG.error("Websocket session onConnect when already connected");
52 return;
53 }
54 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
55 this.webSocketSession = webSocketSession;
56 }
57
58 @OnWebSocketClose
59 public void onClose(int statusCode, String reason) {
60 executor.dispose();
61 if (webSocketSession == null) {
62 return;
63 }
64 if (statusCode == StatusCode.NORMAL || statusCode == StatusCode.SHUTDOWN) {
65 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
66 } else {
67 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
68 reason);
69 }
70 webSocketSession = null;
71 }
72
73 @OnWebSocketError
74 public void onError(Throwable error) {
75 if (webSocketSession == null) {
76 return;
77 }
78 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
79 }
80
81 @OnWebSocketMessage
82 public void onMessage(Reader reader) {
83 if (webSocketSession == null) {
84 LOG.error("Trying to receive message when websocket is disconnected");
85 return;
86 }
87 XtextWebRequest request;
88 try {
89 request = gson.fromJson(reader, XtextWebRequest.class);
90 } catch (JsonIOException e) {
91 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
92 if (webSocketSession.isOpen()) {
93 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload");
94 }
95 return;
96 } catch (JsonParseException e) {
97 LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e);
98 webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload");
99 return;
100 }
101 try {
102 executor.handleRequest(request);
103 } catch (ResponseHandlerException e) {
104 LOG.warn("Cannot write websocket response", e);
105 if (webSocketSession.isOpen()) {
106 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response");
107 }
108 }
109 }
110
111 @Override
112 public void onResponse(XtextWebResponse response) throws ResponseHandlerException {
113 if (webSocketSession == null) {
114 throw new ResponseHandlerException("Trying to send message when websocket is disconnected");
115 }
116 var responseString = gson.toJson(response);
117 try {
118 webSocketSession.getRemote().sendPartialString(responseString, true, this);
119 } catch (IOException e) {
120 throw new ResponseHandlerException(
121 "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
122 }
123 }
124
125 @Override
126 public void writeFailed(Throwable x) {
127 if (webSocketSession == null) {
128 LOG.error("Cannot complete async write to disconnected websocket", x);
129 return;
130 }
131 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
132 }
133}
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
new file mode 100644
index 00000000..942ca380
--- /dev/null
+++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
@@ -0,0 +1,83 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.time.Duration;
5import java.util.Set;
6
7import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
8import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
9import org.eclipse.jetty.websocket.server.JettyWebSocketCreator;
10import org.eclipse.jetty.websocket.server.JettyWebSocketServlet;
11import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;
12import org.eclipse.xtext.resource.IResourceServiceProvider;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import jakarta.servlet.ServletConfig;
17import jakarta.servlet.ServletException;
18
19public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator {
20
21 private static final long serialVersionUID = -3772740838165122685L;
22
23 public static final String ALLOWED_ORIGINS_SEPARATOR = ";";
24
25 public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin";
26
27 public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1";
28
29 /**
30 * Maximum message size should be large enough to upload a full model file.
31 */
32 private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L;
33
34 private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30);
35
36 private transient Logger log = LoggerFactory.getLogger(getClass());
37
38 private transient Set<String> allowedOrigins = null;
39
40 @Override
41 public void init(ServletConfig config) throws ServletException {
42 var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM);
43 if (allowedOriginsStr == null) {
44 log.warn("All WebSocket origins are allowed! This setting should not be used in production!");
45 } else {
46 allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR));
47 log.info("Allowed origins: {}", allowedOrigins);
48 }
49 super.init(config);
50 }
51
52 @Override
53 protected void configure(JettyWebSocketServletFactory factory) {
54 factory.setMaxFrameSize(MAX_FRAME_SIZE);
55 factory.setIdleTimeout(IDLE_TIMEOUT);
56 factory.addMapping("/", this);
57 }
58
59 @Override
60 public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) {
61 if (allowedOrigins != null) {
62 var origin = req.getOrigin();
63 if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) {
64 log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin);
65 try {
66 resp.sendForbidden("Origin not allowed");
67 } catch (IOException e) {
68 log.error("Cannot send forbidden origin error", e);
69 }
70 return null;
71 }
72 }
73 if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) {
74 resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1);
75 } else {
76 log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(),
77 req.getRemoteSocketAddress());
78 resp.setAcceptedSubProtocol(null);
79 }
80 var session = new SimpleSession();
81 return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE);
82 }
83}
diff --git a/language-web/src/main/js/App.tsx b/language-web/src/main/js/App.tsx
index d25ac4d3..2567aa9c 100644
--- a/language-web/src/main/js/App.tsx
+++ b/language-web/src/main/js/App.tsx
@@ -1,15 +1,14 @@
1import AppBar from '@mui/material/AppBar'; 1import AppBar from '@mui/material/AppBar';
2import Box from '@mui/material/Box'; 2import Box from '@mui/material/Box';
3import Button from '@mui/material/Button';
4import IconButton from '@mui/material/IconButton'; 3import IconButton from '@mui/material/IconButton';
5import Toolbar from '@mui/material/Toolbar'; 4import Toolbar from '@mui/material/Toolbar';
6import Typography from '@mui/material/Typography'; 5import Typography from '@mui/material/Typography';
7import MenuIcon from '@mui/icons-material/Menu'; 6import MenuIcon from '@mui/icons-material/Menu';
8import PlayArrowIcon from '@mui/icons-material/PlayArrow';
9import React from 'react'; 7import React from 'react';
10 8
11import { EditorArea } from './editor/EditorArea'; 9import { EditorArea } from './editor/EditorArea';
12import { EditorButtons } from './editor/EditorButtons'; 10import { EditorButtons } from './editor/EditorButtons';
11import { GenerateButton } from './editor/GenerateButton';
13 12
14export const App = (): JSX.Element => ( 13export const App = (): JSX.Element => (
15 <Box 14 <Box
@@ -46,13 +45,7 @@ export const App = (): JSX.Element => (
46 p={1} 45 p={1}
47 > 46 >
48 <EditorButtons /> 47 <EditorButtons />
49 <Button 48 <GenerateButton />
50 variant="outlined"
51 color="primary"
52 startIcon={<PlayArrowIcon />}
53 >
54 Generate
55 </Button>
56 </Box> 49 </Box>
57 <Box 50 <Box
58 flexGrow={1} 51 flexGrow={1}
diff --git a/language-web/src/main/js/RootStore.tsx b/language-web/src/main/js/RootStore.tsx
index 88b8a445..96e1b26a 100644
--- a/language-web/src/main/js/RootStore.tsx
+++ b/language-web/src/main/js/RootStore.tsx
@@ -8,9 +8,9 @@ export class RootStore {
8 8
9 themeStore; 9 themeStore;
10 10
11 constructor() { 11 constructor(initialValue: string) {
12 this.themeStore = new ThemeStore(); 12 this.themeStore = new ThemeStore();
13 this.editorStore = new EditorStore(this.themeStore); 13 this.editorStore = new EditorStore(initialValue, this.themeStore);
14 } 14 }
15} 15}
16 16
diff --git a/language-web/src/main/js/editor/EditorArea.tsx b/language-web/src/main/js/editor/EditorArea.tsx
index 531a57c9..678a632d 100644
--- a/language-web/src/main/js/editor/EditorArea.tsx
+++ b/language-web/src/main/js/editor/EditorArea.tsx
@@ -1,41 +1,151 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint';
1import { observer } from 'mobx-react-lite'; 4import { observer } from 'mobx-react-lite';
2import React, { useRef } from 'react'; 5import React, {
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
3 11
12import { EditorParent } from './EditorParent';
4import { useRootStore } from '../RootStore'; 13import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger';
15
16const log = getLogger('editor.EditorArea');
17
18function usePanel(
19 panelId: string,
20 stateToSet: boolean,
21 editorView: EditorView | null,
22 openCommand: Command,
23 closeCommand: Command,
24 closeCallback: () => void,
25) {
26 const [cachedViewState, setCachedViewState] = useState<boolean>(false);
27 useEffect(() => {
28 if (editorView === null || cachedViewState === stateToSet) {
29 return;
30 }
31 if (stateToSet) {
32 openCommand(editorView);
33 const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
34 const closeButton = editorView.dom.querySelector(buttonQuery);
35 if (closeButton) {
36 log.debug('Addig close button callback to', panelId, 'panel');
37 // We must remove the event listener added by CodeMirror from the button
38 // that dispatches a transaction without going through `EditorStorre`.
39 // Cloning a DOM node removes event listeners,
40 // see https://stackoverflow.com/a/9251864
41 const closeButtonWithoutListeners = closeButton.cloneNode(true);
42 closeButtonWithoutListeners.addEventListener('click', (event) => {
43 closeCallback();
44 event.preventDefault();
45 });
46 closeButton.replaceWith(closeButtonWithoutListeners);
47 } else {
48 log.error('Opened', panelId, 'panel has no close button');
49 }
50 } else {
51 closeCommand(editorView);
52 }
53 setCachedViewState(stateToSet);
54 }, [
55 stateToSet,
56 editorView,
57 cachedViewState,
58 panelId,
59 openCommand,
60 closeCommand,
61 closeCallback,
62 ]);
63 return setCachedViewState;
64}
65
66function fixCodeMirrorAccessibility(editorView: EditorView) {
67 // Reported by Lighthouse 8.3.0.
68 const { contentDOM } = editorView;
69 contentDOM.removeAttribute('aria-expanded');
70 contentDOM.setAttribute('aria-label', 'Code editor');
71}
5 72
6export const EditorArea = observer(() => { 73export const EditorArea = observer(() => {
7 const { editorStore } = useRootStore(); 74 const { editorStore } = useRootStore();
8 const { CodeMirror } = editorStore.chunk || {}; 75 const editorParentRef = useRef<HTMLDivElement | null>(null);
9 const fallbackTextarea = useRef<HTMLTextAreaElement>(null); 76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null);
10 77
11 if (!CodeMirror) { 78 const setSearchPanelOpen = usePanel(
12 return ( 79 'search',
13 <textarea 80 editorStore.showSearchPanel,
14 value={editorStore.value} 81 editorViewState,
15 onChange={(e) => editorStore.updateValue(e.target.value)} 82 openSearchPanel,
16 ref={fallbackTextarea} 83 closeSearchPanel,
17 className={`problem-fallback-editor cm-s-${editorStore.codeMirrorTheme}`} 84 useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
18 /> 85 );
19 ); 86
20 } 87 const setLintPanelOpen = usePanel(
21 88 'panel-lint',
22 const textarea = fallbackTextarea.current; 89 editorStore.showLintPanel,
23 if (textarea) { 90 editorViewState,
24 editorStore.setInitialSelection( 91 openLintPanel,
25 textarea.selectionStart, 92 closeLintPanel,
26 textarea.selectionEnd, 93 useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
27 document.activeElement === textarea, 94 );
28 ); 95
29 } 96 useEffect(() => {
97 if (editorParentRef.current === null) {
98 // Nothing to clean up.
99 return () => {};
100 }
101
102 const editorView = new EditorView({
103 state: editorStore.state,
104 parent: editorParentRef.current,
105 dispatch: (transaction) => {
106 editorStore.onTransaction(transaction);
107 editorView.update([transaction]);
108 if (editorView.state !== editorStore.state) {
109 log.error(
110 'Failed to synchronize editor state - store state:',
111 editorStore.state,
112 'view state:',
113 editorView.state,
114 );
115 }
116 },
117 });
118 fixCodeMirrorAccessibility(editorView);
119 setEditorViewState(editorView);
120 setSearchPanelOpen(false);
121 setLintPanelOpen(false);
122 // `dispatch` is bound to the view instance,
123 // so it does not have to be called as a method.
124 // eslint-disable-next-line @typescript-eslint/unbound-method
125 editorStore.updateDispatcher(editorView.dispatch);
126 log.info('Editor created');
127
128 return () => {
129 editorStore.updateDispatcher(null);
130 editorView.destroy();
131 log.info('Editor destroyed');
132 };
133 }, [
134 editorParentRef,
135 editorStore,
136 setSearchPanelOpen,
137 setLintPanelOpen,
138 ]);
30 139
31 return ( 140 return (
32 <CodeMirror 141 <EditorParent
33 value={editorStore.value} 142 className="dark"
34 options={editorStore.codeMirrorOptions} 143 sx={{
35 editorDidMount={(editor) => editorStore.editorDidMount(editor)} 144 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : {
36 editorWillUnmount={() => editorStore.editorWillUnmount()} 145 display: 'none !important',
37 onBeforeChange={(_editor, _data, value) => editorStore.updateValue(value)} 146 },
38 onChange={() => editorStore.reportChanged()} 147 }}
148 ref={editorParentRef}
39 /> 149 />
40 ); 150 );
41}); 151});
diff --git a/language-web/src/main/js/editor/EditorButtons.tsx b/language-web/src/main/js/editor/EditorButtons.tsx
index 56577e82..09ce33dd 100644
--- a/language-web/src/main/js/editor/EditorButtons.tsx
+++ b/language-web/src/main/js/editor/EditorButtons.tsx
@@ -1,14 +1,36 @@
1import type { Diagnostic } from '@codemirror/lint';
1import { observer } from 'mobx-react-lite'; 2import { observer } from 'mobx-react-lite';
3import IconButton from '@mui/material/IconButton';
2import Stack from '@mui/material/Stack'; 4import Stack from '@mui/material/Stack';
3import ToggleButton from '@mui/material/ToggleButton'; 5import ToggleButton from '@mui/material/ToggleButton';
4import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; 6import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
7import CheckIcon from '@mui/icons-material/Check';
8import ErrorIcon from '@mui/icons-material/Error';
5import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; 9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
10import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
6import RedoIcon from '@mui/icons-material/Redo'; 11import RedoIcon from '@mui/icons-material/Redo';
12import SearchIcon from '@mui/icons-material/Search';
7import UndoIcon from '@mui/icons-material/Undo'; 13import UndoIcon from '@mui/icons-material/Undo';
14import WarningIcon from '@mui/icons-material/Warning';
8import React from 'react'; 15import React from 'react';
9 16
10import { useRootStore } from '../RootStore'; 17import { useRootStore } from '../RootStore';
11 18
19// Exhastive switch as proven by TypeScript.
20// eslint-disable-next-line consistent-return
21function getLintIcon(severity: Diagnostic['severity'] | null) {
22 switch (severity) {
23 case 'error':
24 return <ErrorIcon fontSize="small" />;
25 case 'warning':
26 return <WarningIcon fontSize="small" />;
27 case 'info':
28 return <InfoOutlinedIcon fontSize="small" />;
29 case null:
30 return <CheckIcon fontSize="small" />;
31 }
32}
33
12export const EditorButtons = observer(() => { 34export const EditorButtons = observer(() => {
13 const { editorStore } = useRootStore(); 35 const { editorStore } = useRootStore();
14 36
@@ -17,35 +39,55 @@ export const EditorButtons = observer(() => {
17 direction="row" 39 direction="row"
18 spacing={1} 40 spacing={1}
19 > 41 >
20 <ToggleButtonGroup 42 <Stack
21 size="small" 43 direction="row"
44 alignItems="center"
22 > 45 >
23 <ToggleButton 46 <IconButton
24 disabled={!editorStore.canUndo} 47 disabled={!editorStore.canUndo}
25 onClick={() => editorStore.undo()} 48 onClick={() => editorStore.undo()}
26 aria-label="Undo" 49 aria-label="Undo"
27 value="undo" 50 value="undo"
28 > 51 >
29 <UndoIcon fontSize="small" /> 52 <UndoIcon fontSize="small" />
30 </ToggleButton> 53 </IconButton>
31 <ToggleButton 54 <IconButton
32 disabled={!editorStore.canRedo} 55 disabled={!editorStore.canRedo}
33 onClick={() => editorStore.redo()} 56 onClick={() => editorStore.redo()}
34 aria-label="Redo" 57 aria-label="Redo"
35 value="redo" 58 value="redo"
36 > 59 >
37 <RedoIcon fontSize="small" /> 60 <RedoIcon fontSize="small" />
38 </ToggleButton> 61 </IconButton>
39 </ToggleButtonGroup> 62 </Stack>
40 <ToggleButton 63 <ToggleButtonGroup
41 selected={editorStore.showLineNumbers}
42 onChange={() => editorStore.toggleLineNumbers()}
43 size="small" 64 size="small"
44 aria-label="Show line numbers"
45 value="show-line-numbers"
46 > 65 >
47 <FormatListNumberedIcon fontSize="small" /> 66 <ToggleButton
48 </ToggleButton> 67 selected={editorStore.showLineNumbers}
68 onClick={() => editorStore.toggleLineNumbers()}
69 aria-label="Show line numbers"
70 value="show-line-numbers"
71 >
72 <FormatListNumberedIcon fontSize="small" />
73 </ToggleButton>
74 <ToggleButton
75 selected={editorStore.showSearchPanel}
76 onClick={() => editorStore.toggleSearchPanel()}
77 aria-label="Show find/replace"
78 value="show-search-panel"
79 >
80 <SearchIcon fontSize="small" />
81 </ToggleButton>
82 <ToggleButton
83 selected={editorStore.showLintPanel}
84 onClick={() => editorStore.toggleLintPanel()}
85 aria-label="Show diagnostics panel"
86 value="show-lint-panel"
87 >
88 {getLintIcon(editorStore.highestDiagnosticLevel)}
89 </ToggleButton>
90 </ToggleButtonGroup>
49 </Stack> 91 </Stack>
50 ); 92 );
51}); 93});
diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts
new file mode 100644
index 00000000..ee1323f6
--- /dev/null
+++ b/language-web/src/main/js/editor/EditorParent.ts
@@ -0,0 +1,200 @@
1import { styled } from '@mui/material/styles';
2
3/**
4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64.
5 *
6 * Based on
7 * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501
8 *
9 * @param color the color of the underline
10 * @returns the CSS `url()`
11 */
12function underline(color: string) {
13 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">
14 <path d="m0 3 l2 -2 l1 0 l2 2 l1 0" stroke="${color}" fill="none" stroke-width=".7"/>
15 </svg>`;
16 const svgBase64 = window.btoa(svg);
17 return `url('data:image/svg+xml;base64,${svgBase64}')`;
18}
19
20export const EditorParent = styled('div')(({ theme }) => {
21 const codeMirrorLintStyle: Record<string, unknown> = {};
22 (['error', 'warning', 'info'] as const).forEach((severity) => {
23 const color = theme.palette[severity].main;
24 codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = {
25 borderLeftColor: color,
26 };
27 codeMirrorLintStyle[`.cm-lintRange-${severity}`] = {
28 backgroundImage: underline(color),
29 };
30 });
31
32 return {
33 background: theme.palette.background.default,
34 '&, .cm-editor': {
35 height: '100%',
36 },
37 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': {
38 fontSize: 16,
39 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
40 fontFeatureSettings: '"liga", "calt"',
41 fontWeight: 400,
42 letterSpacing: 0,
43 textRendering: 'optimizeLegibility',
44 },
45 '.cm-scroller': {
46 color: theme.palette.text.secondary,
47 },
48 '.cm-gutters': {
49 background: theme.palette.background.default,
50 color: theme.palette.text.disabled,
51 border: 'none',
52 },
53 '.cm-specialChar': {
54 color: theme.palette.secondary.main,
55 },
56 '.cm-activeLine': {
57 background: 'rgba(0, 0, 0, 0.3)',
58 },
59 '.cm-activeLineGutter': {
60 background: 'rgba(0, 0, 0, 0.3)',
61 color: theme.palette.text.primary,
62 },
63 '.cm-cursor, .cm-cursor-primary': {
64 borderColor: theme.palette.primary.main,
65 background: theme.palette.common.black,
66 },
67 '.cm-selectionBackground': {
68 background: '#3e4453',
69 },
70 '.cm-focused': {
71 outline: 'none',
72 '.cm-selectionBackground': {
73 background: '#3e4453',
74 },
75 },
76 '.cm-panels-top': {
77 color: theme.palette.text.secondary,
78 },
79 '.cm-panel': {
80 '&, & button, & input': {
81 fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
82 },
83 background: theme.palette.background.paper,
84 borderTop: `1px solid ${theme.palette.divider}`,
85 'button[name="close"]': {
86 background: 'transparent',
87 color: theme.palette.text.secondary,
88 cursor: 'pointer',
89 },
90 },
91 '.cm-panel.cm-panel-lint': {
92 'button[name="close"]': {
93 // Close button interferes with scrollbar, so we better hide it.
94 // The panel can still be closed from the toolbar.
95 display: 'none',
96 },
97 ul: {
98 li: {
99 borderBottom: `1px solid ${theme.palette.divider}`,
100 cursor: 'pointer',
101 },
102 '[aria-selected]': {
103 background: '#3e4453',
104 color: theme.palette.text.primary,
105 },
106 '&:focus [aria-selected]': {
107 background: theme.palette.primary.main,
108 color: theme.palette.primary.contrastText,
109 },
110 },
111 },
112 '.cm-foldPlaceholder': {
113 background: theme.palette.background.paper,
114 borderColor: theme.palette.text.disabled,
115 color: theme.palette.text.secondary,
116 },
117 '.cmt-comment': {
118 fontStyle: 'italic',
119 color: theme.palette.text.disabled,
120 },
121 '.cmt-number': {
122 color: '#6188a6',
123 },
124 '.cmt-string': {
125 color: theme.palette.secondary.dark,
126 },
127 '.cmt-keyword': {
128 color: theme.palette.primary.main,
129 },
130 '.cmt-typeName, .cmt-macroName, .cmt-atom': {
131 color: theme.palette.text.primary,
132 },
133 '.cmt-variableName': {
134 color: '#c8ae9d',
135 },
136 '.cmt-problem-node': {
137 '&, & .cmt-variableName': {
138 color: theme.palette.text.secondary,
139 },
140 },
141 '.cmt-problem-unique': {
142 '&, & .cmt-variableName': {
143 color: theme.palette.text.primary,
144 },
145 },
146 '.cmt-problem-abstract, .cmt-problem-new': {
147 fontStyle: 'italic',
148 },
149 '.cmt-problem-containment': {
150 fontWeight: 700,
151 },
152 '.cmt-problem-error': {
153 '&, & .cmt-typeName': {
154 color: theme.palette.error.main,
155 },
156 },
157 '.cmt-problem-builtin': {
158 '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': {
159 color: theme.palette.primary.main,
160 fontWeight: 400,
161 fontStyle: 'normal',
162 },
163 },
164 '.cm-tooltip-autocomplete': {
165 background: theme.palette.background.paper,
166 boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%),
167 0px 4px 5px 0px rgb(0 0 0 / 14%),
168 0px 1px 10px 0px rgb(0 0 0 / 12%)`,
169 '.cm-completionIcon': {
170 color: theme.palette.text.secondary,
171 },
172 '.cm-completionLabel': {
173 color: theme.palette.text.primary,
174 },
175 '.cm-completionDetail': {
176 color: theme.palette.text.secondary,
177 fontStyle: 'normal',
178 },
179 '[aria-selected]': {
180 background: `${theme.palette.primary.main} !important`,
181 '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': {
182 color: theme.palette.primary.contrastText,
183 },
184 },
185 },
186 '.cm-completionIcon': {
187 width: 16,
188 padding: 0,
189 marginRight: '0.5em',
190 textAlign: 'center',
191 },
192 ...codeMirrorLintStyle,
193 '.cm-problem-write': {
194 background: 'rgba(255, 255, 128, 0.3)',
195 },
196 '.cm-problem-read': {
197 background: 'rgba(255, 255, 255, 0.15)',
198 },
199 };
200});
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts
index 705020b9..ba31efcb 100644
--- a/language-web/src/main/js/editor/EditorStore.ts
+++ b/language-web/src/main/js/editor/EditorStore.ts
@@ -1,201 +1,283 @@
1import type { Editor, EditorConfiguration } from 'codemirror'; 1import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
2import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
3import { defaultKeymap, indentWithTab } from '@codemirror/commands';
4import { commentKeymap } from '@codemirror/comment';
5import { foldGutter, foldKeymap } from '@codemirror/fold';
6import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
7import { classHighlightStyle } from '@codemirror/highlight';
8import {
9 history,
10 historyKeymap,
11 redo,
12 redoDepth,
13 undo,
14 undoDepth,
15} from '@codemirror/history';
16import { indentOnInput } from '@codemirror/language';
17import {
18 Diagnostic,
19 lintKeymap,
20 setDiagnostics,
21} from '@codemirror/lint';
22import { bracketMatching } from '@codemirror/matchbrackets';
23import { rectangularSelection } from '@codemirror/rectangular-selection';
24import { searchConfig, searchKeymap } from '@codemirror/search';
25import {
26 EditorState,
27 StateCommand,
28 StateEffect,
29 Transaction,
30 TransactionSpec,
31} from '@codemirror/state';
32import {
33 drawSelection,
34 EditorView,
35 highlightActiveLine,
36 highlightSpecialChars,
37 keymap,
38} from '@codemirror/view';
2import { 39import {
3 createAtom,
4 makeAutoObservable, 40 makeAutoObservable,
5 observable, 41 observable,
6 runInAction, 42 reaction,
7} from 'mobx'; 43} from 'mobx';
8import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror';
9 44
10import type { IEditorChunk } from './editor'; 45import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences';
11import { getLogger } from '../logging'; 46import { problemLanguageSupport } from '../language/problemLanguageSupport';
47import {
48 IHighlightRange,
49 semanticHighlighting,
50 setSemanticHighlighting,
51} from './semanticHighlighting';
12import type { ThemeStore } from '../theme/ThemeStore'; 52import type { ThemeStore } from '../theme/ThemeStore';
53import { getLogger } from '../utils/logger';
54import { XtextClient } from '../xtext/XtextClient';
13 55
14const log = getLogger('EditorStore'); 56const log = getLogger('editor.EditorStore');
15 57
16const xtextLang = 'problem'; 58export class EditorStore {
59 private readonly themeStore;
17 60
18const xtextOptions: IXtextOptions = { 61 state: EditorState;
19 xtextLang,
20 enableFormattingAction: true,
21};
22 62
23const codeMirrorGlobalOptions: EditorConfiguration = { 63 private readonly client: XtextClient;
24 mode: `xtext/${xtextLang}`,
25 indentUnit: 2,
26 styleActiveLine: true,
27 screenReaderLabel: 'Model source code',
28 inputStyle: 'contenteditable',
29};
30 64
31export class EditorStore { 65 showLineNumbers = false;
32 themeStore;
33 66
34 atom; 67 showSearchPanel = false;
35 68
36 chunk?: IEditorChunk; 69 showLintPanel = false;
37 70
38 editor?: Editor; 71 errorCount = 0;
39 72
40 xtextServices?: IXtextServices; 73 warningCount = 0;
41 74
42 value = ''; 75 infoCount = 0;
43 76
44 showLineNumbers = false; 77 private readonly defaultDispatcher = (tr: Transaction): void => {
78 this.onTransaction(tr);
79 };
45 80
46 initialSelection!: { start: number, end: number, focused: boolean }; 81 private dispatcher = this.defaultDispatcher;
47 82
48 constructor(themeStore: ThemeStore) { 83 constructor(initialValue: string, themeStore: ThemeStore) {
49 this.themeStore = themeStore; 84 this.themeStore = themeStore;
50 this.atom = createAtom('EditorStore'); 85 this.state = EditorState.create({
51 this.resetInitialSelection(); 86 doc: initialValue,
52 makeAutoObservable(this, { 87 extensions: [
53 themeStore: false, 88 autocompletion({
54 atom: false, 89 activateOnTyping: true,
55 chunk: observable.ref, 90 override: [
56 editor: observable.ref, 91 (context) => this.client.contentAssist(context),
57 xtextServices: observable.ref, 92 ],
58 initialSelection: false, 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 foldGutter(),
116 lineNumbers(),
117 keymap.of([
118 ...closeBracketsKeymap,
119 ...commentKeymap,
120 ...completionKeymap,
121 ...foldKeymap,
122 ...historyKeymap,
123 indentWithTab,
124 // Override keys in `lintKeymap` to go through the `EditorStore`.
125 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
126 ...lintKeymap,
127 // Override keys in `searchKeymap` to go through the `EditorStore`.
128 { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' },
129 { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' },
130 ...searchKeymap,
131 ...defaultKeymap,
132 ]),
133 problemLanguageSupport(),
134 ],
59 }); 135 });
60 this.loadChunk(); 136 this.client = new XtextClient(this);
61 } 137 reaction(
62 138 () => this.themeStore.darkMode,
63 private loadChunk(): void { 139 (darkMode) => {
64 const loadingStartMillis = Date.now(); 140 log.debug('Update editor dark mode', darkMode);
65 log.info('Requesting editor chunk'); 141 this.dispatch({
66 import('./editor').then(({ editorChunk }) => { 142 effects: [
67 runInAction(() => { 143 StateEffect.appendConfig.of(EditorView.theme({}, {
68 this.chunk = editorChunk; 144 dark: darkMode,
69 }); 145 })),
70 const loadingDurationMillis = Date.now() - loadingStartMillis; 146 ],
71 log.info('Loaded editor chunk in', loadingDurationMillis, 'ms'); 147 });
72 }).catch((error) => { 148 },
73 log.error('Error while loading editor', error); 149 );
150 makeAutoObservable(this, {
151 state: observable.ref,
74 }); 152 });
75 } 153 }
76 154
77 setInitialSelection(start: number, end: number, focused: boolean): void { 155 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void {
78 this.initialSelection = { start, end, focused }; 156 this.dispatcher = newDispatcher || this.defaultDispatcher;
79 this.applyInitialSelectionToEditor(); 157 }
158
159 onTransaction(tr: Transaction): void {
160 log.trace('Editor transaction', tr);
161 this.state = tr.state;
162 this.client.onTransaction(tr);
80 } 163 }
81 164
82 private resetInitialSelection(): void { 165 dispatch(...specs: readonly TransactionSpec[]): void {
83 this.initialSelection = { 166 this.dispatcher(this.state.update(...specs));
84 start: 0,
85 end: 0,
86 focused: false,
87 };
88 } 167 }
89 168
90 private applyInitialSelectionToEditor(): void { 169 doStateCommand(command: StateCommand): boolean {
91 if (this.editor) { 170 return command({
92 const { start, end, focused } = this.initialSelection; 171 state: this.state,
93 const doc = this.editor.getDoc(); 172 dispatch: this.dispatcher,
94 const startPos = doc.posFromIndex(start); 173 });
95 const endPos = doc.posFromIndex(end); 174 }
96 doc.setSelection(startPos, endPos, { 175
97 scroll: true, 176 updateDiagnostics(diagnostics: Diagnostic[]): void {
98 }); 177 this.dispatch(setDiagnostics(this.state, diagnostics));
99 if (focused) { 178 this.errorCount = 0;
100 this.editor.focus(); 179 this.warningCount = 0;
180 this.infoCount = 0;
181 diagnostics.forEach(({ severity }) => {
182 switch (severity) {
183 case 'error':
184 this.errorCount += 1;
185 break;
186 case 'warning':
187 this.warningCount += 1;
188 break;
189 case 'info':
190 this.infoCount += 1;
191 break;
101 } 192 }
102 this.resetInitialSelection(); 193 });
103 }
104 } 194 }
105 195
106 /** 196 get highestDiagnosticLevel(): Diagnostic['severity'] | null {
107 * Attaches a new CodeMirror instance and creates Xtext services. 197 if (this.errorCount > 0) {
108 * 198 return 'error';
109 * The store will not subscribe to any CodeMirror events. Instead, 199 }
110 * the editor component should subscribe to them and relay them to the store. 200 if (this.warningCount > 0) {
111 * 201 return 'warning';
112 * @param newEditor The new CodeMirror instance
113 */
114 editorDidMount(newEditor: Editor): void {
115 if (!this.chunk) {
116 throw new Error('Editor not loaded yet');
117 } 202 }
118 if (this.editor) { 203 if (this.infoCount > 0) {
119 throw new Error('CoreMirror editor mounted before unmounting'); 204 return 'info';
120 } 205 }
121 this.editor = newEditor; 206 return null;
122 this.xtextServices = this.chunk.createServices(newEditor, xtextOptions);
123 this.applyInitialSelectionToEditor();
124 } 207 }
125 208
126 editorWillUnmount(): void { 209 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
127 if (!this.chunk) { 210 this.dispatch(setSemanticHighlighting(ranges));
128 throw new Error('Editor not loaded yet'); 211 }
129 } 212
130 if (this.editor) { 213 updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void {
131 this.chunk.removeServices(this.editor); 214 this.dispatch(setOccurrences(write, read));
132 }
133 delete this.editor;
134 delete this.xtextServices;
135 } 215 }
136 216
137 /** 217 /**
138 * Updates the contents of the editor. 218 * @returns `true` if there is history to undo
139 *
140 * @param newValue The new contents of the editor
141 */ 219 */
142 updateValue(newValue: string): void { 220 get canUndo(): boolean {
143 this.value = newValue; 221 return undoDepth(this.state) > 0;
144 } 222 }
145 223
146 reportChanged(): void { 224 // eslint-disable-next-line class-methods-use-this
147 this.atom.reportChanged(); 225 undo(): void {
226 log.debug('Undo', this.doStateCommand(undo));
148 } 227 }
149 228
150 protected observeEditorChanges(): void { 229 /**
151 this.atom.reportObserved(); 230 * @returns `true` if there is history to redo
231 */
232 get canRedo(): boolean {
233 return redoDepth(this.state) > 0;
152 } 234 }
153 235
154 get codeMirrorTheme(): string { 236 // eslint-disable-next-line class-methods-use-this
155 return `problem-${this.themeStore.className}`; 237 redo(): void {
238 log.debug('Redo', this.doStateCommand(redo));
156 } 239 }
157 240
158 get codeMirrorOptions(): EditorConfiguration { 241 toggleLineNumbers(): void {
159 return { 242 this.showLineNumbers = !this.showLineNumbers;
160 ...codeMirrorGlobalOptions, 243 log.debug('Show line numbers', this.showLineNumbers);
161 theme: this.codeMirrorTheme,
162 lineNumbers: this.showLineNumbers,
163 };
164 } 244 }
165 245
166 /** 246 /**
167 * @returns `true` if there is history to undo 247 * Sets whether the CodeMirror search panel should be open.
248 *
249 * This method can be used as a CodeMirror command,
250 * because it returns `false` if it didn't execute,
251 * allowing other commands for the same keybind to run instead.
252 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
253 * commands from `'@codemirror/search'`.
254 *
255 * @param newShosSearchPanel whether we should show the search panel
256 * @returns `true` if the state was changed, `false` otherwise
168 */ 257 */
169 get canUndo(): boolean { 258 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
170 this.observeEditorChanges(); 259 if (this.showSearchPanel === newShowSearchPanel) {
171 if (!this.editor) {
172 return false; 260 return false;
173 } 261 }
174 const { undo: undoSize } = this.editor.historySize(); 262 this.showSearchPanel = newShowSearchPanel;
175 return undoSize > 0; 263 log.debug('Show search panel', this.showSearchPanel);
264 return true;
176 } 265 }
177 266
178 undo(): void { 267 toggleSearchPanel(): void {
179 this.editor?.undo(); 268 this.setSearchPanelOpen(!this.showSearchPanel);
180 } 269 }
181 270
182 /** 271 setLintPanelOpen(newShowLintPanel: boolean): boolean {
183 * @returns `true` if there is history to redo 272 if (this.showLintPanel === newShowLintPanel) {
184 */
185 get canRedo(): boolean {
186 this.observeEditorChanges();
187 if (!this.editor) {
188 return false; 273 return false;
189 } 274 }
190 const { redo: redoSize } = this.editor.historySize(); 275 this.showLintPanel = newShowLintPanel;
191 return redoSize > 0; 276 log.debug('Show lint panel', this.showLintPanel);
192 } 277 return true;
193
194 redo(): void {
195 this.editor?.redo();
196 } 278 }
197 279
198 toggleLineNumbers(): void { 280 toggleLintPanel(): void {
199 this.showLineNumbers = !this.showLineNumbers; 281 this.setLintPanelOpen(!this.showLintPanel);
200 } 282 }
201} 283}
diff --git a/language-web/src/main/js/editor/GenerateButton.tsx b/language-web/src/main/js/editor/GenerateButton.tsx
new file mode 100644
index 00000000..3834cec4
--- /dev/null
+++ b/language-web/src/main/js/editor/GenerateButton.tsx
@@ -0,0 +1,44 @@
1import { observer } from 'mobx-react-lite';
2import Button from '@mui/material/Button';
3import PlayArrowIcon from '@mui/icons-material/PlayArrow';
4import React from 'react';
5
6import { useRootStore } from '../RootStore';
7
8const GENERATE_LABEL = 'Generate';
9
10export const GenerateButton = observer(() => {
11 const { editorStore } = useRootStore();
12 const { errorCount, warningCount } = editorStore;
13
14 const diagnostics: string[] = [];
15 if (errorCount > 0) {
16 diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
17 }
18 if (warningCount > 0) {
19 diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
20 }
21 const summary = diagnostics.join(' and ');
22
23 if (errorCount > 0) {
24 return (
25 <Button
26 variant="outlined"
27 color="error"
28 onClick={() => editorStore.toggleLintPanel()}
29 >
30 {summary}
31 </Button>
32 );
33 }
34
35 return (
36 <Button
37 variant="outlined"
38 color={warningCount > 0 ? 'warning' : 'primary'}
39 startIcon={<PlayArrowIcon />}
40 >
41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
42 </Button>
43 );
44});
diff --git a/language-web/src/main/js/editor/decorationSetExtension.ts b/language-web/src/main/js/editor/decorationSetExtension.ts
new file mode 100644
index 00000000..2d630c20
--- /dev/null
+++ b/language-web/src/main/js/editor/decorationSetExtension.ts
@@ -0,0 +1,39 @@
1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state';
2import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
3
4export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec;
5
6export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] {
7 const setEffect = StateEffect.define<DecorationSet>();
8 const field = StateField.define<DecorationSet>({
9 create() {
10 return Decoration.none;
11 },
12 update(currentDecorations, transaction) {
13 let newDecorations: DecorationSet | null = null;
14 transaction.effects.forEach((effect) => {
15 if (effect.is(setEffect)) {
16 newDecorations = effect.value;
17 }
18 });
19 if (newDecorations === null) {
20 if (transaction.docChanged) {
21 return currentDecorations.map(transaction.changes);
22 }
23 return currentDecorations;
24 }
25 return newDecorations;
26 },
27 provide: (f) => EditorView.decorations.from(f),
28 });
29
30 function transactionSpecFactory(decorations: DecorationSet) {
31 return {
32 effects: [
33 setEffect.of(decorations),
34 ],
35 };
36 }
37
38 return [transactionSpecFactory, field];
39}
diff --git a/language-web/src/main/js/editor/editor.ts b/language-web/src/main/js/editor/editor.ts
deleted file mode 100644
index fbf8796b..00000000
--- a/language-web/src/main/js/editor/editor.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import 'codemirror/addon/selection/active-line';
2import 'mode-problem';
3import { Controlled } from 'react-codemirror2';
4import { createServices, removeServices } from 'xtext/xtext-codemirror';
5
6export interface IEditorChunk {
7 CodeMirror: typeof Controlled;
8
9 createServices: typeof createServices;
10
11 removeServices: typeof removeServices;
12}
13
14export const editorChunk: IEditorChunk = {
15 CodeMirror: Controlled,
16 createServices,
17 removeServices,
18};
diff --git a/language-web/src/main/js/editor/findOccurrences.ts b/language-web/src/main/js/editor/findOccurrences.ts
new file mode 100644
index 00000000..92102746
--- /dev/null
+++ b/language-web/src/main/js/editor/findOccurrences.ts
@@ -0,0 +1,35 @@
1import { Range, RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IOccurrence {
8 from: number;
9
10 to: number;
11}
12
13const [setOccurrencesInteral, findOccurrences] = decorationSetExtension();
14
15const writeDecoration = Decoration.mark({
16 class: 'cm-problem-write',
17});
18
19const readDecoration = Decoration.mark({
20 class: 'cm-problem-read',
21});
22
23export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec {
24 const decorations: Range<Decoration>[] = [];
25 write.forEach(({ from, to }) => {
26 decorations.push(writeDecoration.range(from, to));
27 });
28 read.forEach(({ from, to }) => {
29 decorations.push(readDecoration.range(from, to));
30 });
31 const rangeSet = RangeSet.of(decorations, true);
32 return setOccurrencesInteral(rangeSet);
33}
34
35export { findOccurrences };
diff --git a/language-web/src/main/js/editor/semanticHighlighting.ts b/language-web/src/main/js/editor/semanticHighlighting.ts
new file mode 100644
index 00000000..2aed421b
--- /dev/null
+++ b/language-web/src/main/js/editor/semanticHighlighting.ts
@@ -0,0 +1,24 @@
1import { RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IHighlightRange {
8 from: number;
9
10 to: number;
11
12 classes: string[];
13}
14
15const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension();
16
17export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec {
18 const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({
19 class: classes.map((c) => `cmt-problem-${c}`).join(' '),
20 }).range(from, to)), true);
21 return setSemanticHighlightingInternal(rangeSet);
22}
23
24export { semanticHighlighting };
diff --git a/language-web/src/main/js/index.tsx b/language-web/src/main/js/index.tsx
index 80c70f23..dfecde37 100644
--- a/language-web/src/main/js/index.tsx
+++ b/language-web/src/main/js/index.tsx
@@ -9,23 +9,34 @@ import { ThemeProvider } from './theme/ThemeProvider';
9import '../css/index.scss'; 9import '../css/index.scss';
10 10
11const initialValue = `class Family { 11const initialValue = `class Family {
12 contains Person[] members 12 contains Person[] members
13} 13}
14 14
15class Person { 15class Person {
16 Person[] children opposite parent 16 Person[] children opposite parent
17 Person[0..1] parent opposite children 17 Person[0..1] parent opposite children
18 int age 18 int age
19 TaxStatus taxStatus 19 TaxStatus taxStatus
20} 20}
21 21
22enum TaxStatus { 22enum TaxStatus {
23 child, student, adult, retired 23 child, student, adult, retired
24} 24}
25 25
26% A child cannot have any dependents. 26% A child cannot have any dependents.
27error invalidTaxStatus(Person p) <-> 27pred invalidTaxStatus(Person p) <->
28 taxStatus(p, child), children(p, _q). 28 taxStatus(p, child),
29 children(p, _q)
30 ; taxStatus(p, retired),
31 parent(p, q),
32 !taxStatus(q, retired).
33
34direct rule createChild(p):
35 children(p, newPerson) = unknown,
36 equals(newPerson, newPerson) = unknown
37 ~> new q,
38 children(p, q) = true,
39 taxStatus(q, child) = true.
29 40
30unique family. 41unique family.
31Family(family). 42Family(family).
@@ -44,8 +55,7 @@ age(bob, bobAge).
44scope Family = 1, Person += 5..10. 55scope Family = 1, Person += 5..10.
45`; 56`;
46 57
47const rootStore = new RootStore(); 58const rootStore = new RootStore(initialValue);
48rootStore.editorStore.updateValue(initialValue);
49 59
50const app = ( 60const app = (
51 <RootStoreProvider rootStore={rootStore}> 61 <RootStoreProvider rootStore={rootStore}>
diff --git a/language-web/src/main/js/language/folding.ts b/language-web/src/main/js/language/folding.ts
new file mode 100644
index 00000000..5d51f796
--- /dev/null
+++ b/language-web/src/main/js/language/folding.ts
@@ -0,0 +1,115 @@
1import { EditorState } from '@codemirror/state';
2import type { SyntaxNode } from '@lezer/common';
3
4export type FoldRange = { from: number, to: number };
5
6/**
7 * Folds a block comment between its delimiters.
8 *
9 * @param node the node to fold
10 * @returns the folding range or `null` is there is nothing to fold
11 */
12export function foldBlockComment(node: SyntaxNode): FoldRange {
13 return {
14 from: node.from + 2,
15 to: node.to - 2,
16 };
17}
18
19/**
20 * Folds a declaration after the first element if it appears on the opening line,
21 * otherwise folds after the opening keyword.
22 *
23 * @example
24 * First element on the opening line:
25 * ```
26 * scope Family = 1,
27 * Person += 5..10.
28 * ```
29 * becomes
30 * ```
31 * scope Family = 1,[...].
32 * ```
33 *
34 * @example
35 * First element not on the opening line:
36 * ```
37 * scope Family
38 * = 1,
39 * Person += 5..10.
40 * ```
41 * becomes
42 * ```
43 * scope [...].
44 * ```
45 *
46 * @param node the node to fold
47 * @param state the editor state
48 * @returns the folding range or `null` is there is nothing to fold
49 */
50export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null {
51 const { firstChild: open, lastChild: close } = node;
52 if (open === null || close === null) {
53 return null;
54 }
55 const { cursor } = open;
56 const lineEnd = state.doc.lineAt(open.from).to;
57 let foldFrom = open.to;
58 while (cursor.next() && cursor.from < lineEnd) {
59 if (cursor.type.name === ',') {
60 foldFrom = cursor.to;
61 break;
62 }
63 }
64 return {
65 from: foldFrom,
66 to: close.from,
67 };
68}
69
70/**
71 * Folds a node only if it has at least one sibling of the same type.
72 *
73 * The folding range will be the entire `node`.
74 *
75 * @param node the node to fold
76 * @returns the folding range or `null` is there is nothing to fold
77 */
78function foldWithSibling(node: SyntaxNode): FoldRange | null {
79 const { parent } = node;
80 if (parent === null) {
81 return null;
82 }
83 const { firstChild } = parent;
84 if (firstChild === null) {
85 return null;
86 }
87 const { cursor } = firstChild;
88 let nSiblings = 0;
89 while (cursor.nextSibling()) {
90 if (cursor.type === node.type) {
91 nSiblings += 1;
92 }
93 if (nSiblings >= 2) {
94 return {
95 from: node.from,
96 to: node.to,
97 };
98 }
99 }
100 return null;
101}
102
103export function foldWholeNode(node: SyntaxNode): FoldRange {
104 return {
105 from: node.from,
106 to: node.to,
107 };
108}
109
110export function foldConjunction(node: SyntaxNode): FoldRange | null {
111 if (node.parent?.type?.name === 'PredicateBody') {
112 return foldWithSibling(node);
113 }
114 return foldWholeNode(node);
115}
diff --git a/language-web/src/main/js/language/indentation.ts b/language-web/src/main/js/language/indentation.ts
new file mode 100644
index 00000000..78f0a750
--- /dev/null
+++ b/language-web/src/main/js/language/indentation.ts
@@ -0,0 +1,87 @@
1import { TreeIndentContext } from '@codemirror/language';
2
3/**
4 * Finds the `from` of first non-skipped token, if any,
5 * after the opening keyword in the first line of the declaration.
6 *
7 * Based on
8 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246
9 *
10 * @param context the indentation context
11 * @returns the alignment or `null` if there is no token after the opening keyword
12 */
13function findAlignmentAfterOpening(context: TreeIndentContext): number | null {
14 const {
15 node: tree,
16 simulatedBreak,
17 } = context;
18 const openingToken = tree.childAfter(tree.from);
19 if (openingToken === null) {
20 return null;
21 }
22 const openingLine = context.state.doc.lineAt(openingToken.from);
23 const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from
24 ? openingLine.to
25 : Math.min(openingLine.to, simulatedBreak);
26 const { cursor } = openingToken;
27 while (cursor.next() && cursor.from < lineEnd) {
28 if (!cursor.type.isSkipped) {
29 return cursor.from;
30 }
31 }
32 return null;
33}
34
35/**
36 * Indents text after declarations by a single unit if it begins on a new line,
37 * otherwise it aligns with the text after the declaration.
38 *
39 * Based on
40 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275
41 *
42 * @example
43 * Result with no hanging indent (indent unit = 2 spaces, units = 1):
44 * ```
45 * scope
46 * Family = 1,
47 * Person += 5..10.
48 * ```
49 *
50 * @example
51 * Result with hanging indent:
52 * ```
53 * scope Family = 1,
54 * Person += 5..10.
55 * ```
56 *
57 * @param context the indentation context
58 * @param units the number of units to indent
59 * @returns the desired indentation level
60 */
61function indentDeclarationStrategy(context: TreeIndentContext, units: number): number {
62 const alignment = findAlignmentAfterOpening(context);
63 if (alignment !== null) {
64 return context.column(alignment);
65 }
66 return context.baseIndent + units * context.unit;
67}
68
69export function indentBlockComment(): number {
70 // Do not indent.
71 return -1;
72}
73
74export function indentDeclaration(context: TreeIndentContext): number {
75 return indentDeclarationStrategy(context, 1);
76}
77
78export function indentPredicateOrRule(context: TreeIndentContext): number {
79 const clauseIndent = indentDeclarationStrategy(context, 1);
80 if (/^\s+(;|\.)/.exec(context.textAfter) !== null) {
81 return clauseIndent - 2;
82 }
83 if (/^\s+(~>)/.exec(context.textAfter) !== null) {
84 return clauseIndent - 3;
85 }
86 return clauseIndent;
87}
diff --git a/language-web/src/main/js/language/problem.grammar b/language-web/src/main/js/language/problem.grammar
new file mode 100644
index 00000000..c242a4ba
--- /dev/null
+++ b/language-web/src/main/js/language/problem.grammar
@@ -0,0 +1,145 @@
1@top Problem { statement* }
2
3statement {
4 ProblemDeclaration {
5 ckw<"problem"> QualifiedName "."
6 } |
7 ClassDefinition {
8 ckw<"abstract">? ckw<"class"> RelationName
9 (ckw<"extends"> sep<",", RelationName>)?
10 (ClassBody { "{" ReferenceDeclaration* "}" } | ".")
11 } |
12 EnumDefinition {
13 ckw<"enum"> RelationName
14 (EnumBody { "{" sep<",", UniqueNodeName> "}" } | ".")
15 } |
16 PredicateDefinition {
17 (ckw<"error"> ckw<"pred">? | ckw<"direct">? ckw<"pred">)
18 RelationName ParameterList<Parameter>?
19 PredicateBody { ("<->" sep<OrOp, Conjunction>)? "." }
20 } |
21 RuleDefinition {
22 ckw<"direct">? ckw<"rule">
23 RuleName ParameterList<Parameter>?
24 RuleBody { ":" sep<OrOp, Conjunction> "~>" sep<OrOp, Action> "." }
25 } |
26 Assertion {
27 ckw<"default">? (NotOp | UnknownOp)? RelationName
28 ParameterList<AssertionArgument> (":" LogicValue)? "."
29 } |
30 NodeValueAssertion {
31 UniqueNodeName ":" Constant "."
32 } |
33 UniqueDeclaration {
34 ckw<"unique"> sep<",", UniqueNodeName> "."
35 } |
36 ScopeDeclaration {
37 ckw<"scope"> sep<",", ScopeElement> "."
38 }
39}
40
41ReferenceDeclaration {
42 (kw<"refers"> | kw<"contains">)?
43 RelationName
44 RelationName
45 ( "[" Multiplicity? "]" )?
46 (kw<"opposite"> RelationName)?
47 ";"?
48}
49
50Parameter { RelationName? VariableName }
51
52Conjunction { ("," | Literal)+ }
53
54OrOp { ";" }
55
56Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? }
57
58Atom { RelationName "+"? ParameterList<Argument> }
59
60Action { ("," | ActionLiteral)+ }
61
62ActionLiteral {
63 ckw<"new"> VariableName |
64 ckw<"delete"> VariableName |
65 Literal
66}
67
68Argument { VariableName | Constant }
69
70AssertionArgument { NodeName | StarArgument | Constant }
71
72Constant { Real | String }
73
74LogicValue {
75 ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error">
76}
77
78ScopeElement { RelationName ("=" | "+=") Multiplicity }
79
80Multiplicity { (IntMult "..")? (IntMult | StarMult)}
81
82RelationName { QualifiedName }
83
84RuleName { QualifiedName }
85
86UniqueNodeName { QualifiedName }
87
88VariableName { QualifiedName }
89
90NodeName { QualifiedName }
91
92QualifiedName { identifier ("::" identifier)* }
93
94kw<term> { @specialize[@name={term}]<identifier, term> }
95
96ckw<term> { @extend[@name={term}]<identifier, term> }
97
98ParameterList<content> { "(" sep<",", content> ")" }
99
100sep<separator, content> { sep1<separator, content>? }
101
102sep1<separator, content> { content (separator content)* }
103
104@skip { LineComment | BlockComment | whitespace }
105
106@tokens {
107 whitespace { std.whitespace+ }
108
109 LineComment { ("//" | "%") ![\n]* }
110
111 BlockComment { "/*" blockCommentRest }
112
113 blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar }
114
115 blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest }
116
117 @precedence { BlockComment, LineComment }
118
119 identifier { $[A-Za-z_] $[a-zA-Z0-9_]* }
120
121 int { $[0-9]+ }
122
123 IntMult { int }
124
125 StarMult { "*" }
126
127 Real { "-"? (exponential | int ("." (int | exponential))?) }
128
129 exponential { int ("e" | "E") ("+" | "-")? int }
130
131 String {
132 "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" |
133 "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\""
134 }
135
136 NotOp { "!" }
137
138 UnknownOp { "?" }
139
140 StarArgument { "*" }
141
142 "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" "~>"
143}
144
145@detectDelim
diff --git a/language-web/src/main/js/language/problemLanguageSupport.ts b/language-web/src/main/js/language/problemLanguageSupport.ts
new file mode 100644
index 00000000..ab1c55f9
--- /dev/null
+++ b/language-web/src/main/js/language/problemLanguageSupport.ts
@@ -0,0 +1,92 @@
1import { styleTags, tags as t } from '@codemirror/highlight';
2import {
3 foldInside,
4 foldNodeProp,
5 indentNodeProp,
6 indentUnit,
7 LanguageSupport,
8 LRLanguage,
9} from '@codemirror/language';
10import { LRParser } from '@lezer/lr';
11
12import { parser } from '../../../../build/generated/sources/lezer/problem';
13import {
14 foldBlockComment,
15 foldConjunction,
16 foldDeclaration,
17 foldWholeNode,
18} from './folding';
19import {
20 indentBlockComment,
21 indentDeclaration,
22 indentPredicateOrRule,
23} from './indentation';
24
25const parserWithMetadata = (parser as LRParser).configure({
26 props: [
27 styleTags({
28 LineComment: t.lineComment,
29 BlockComment: t.blockComment,
30 'problem class enum pred rule unique 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 'UniqueNodeName/QualifiedName': t.atom,
44 'VariableName/QualifiedName': t.variableName,
45 '{ }': t.brace,
46 '( )': t.paren,
47 '[ ]': t.squareBracket,
48 '. .. , :': t.separator,
49 '<-> ~>': t.definitionOperator,
50 }),
51 indentNodeProp.add({
52 ProblemDeclaration: indentDeclaration,
53 UniqueDeclaration: indentDeclaration,
54 ScopeDeclaration: indentDeclaration,
55 PredicateBody: indentPredicateOrRule,
56 RuleBody: indentPredicateOrRule,
57 BlockComment: indentBlockComment,
58 }),
59 foldNodeProp.add({
60 ClassBody: foldInside,
61 EnumBody: foldInside,
62 ParameterList: foldInside,
63 PredicateBody: foldInside,
64 RuleBody: foldInside,
65 Conjunction: foldConjunction,
66 Action: foldWholeNode,
67 UniqueDeclaration: foldDeclaration,
68 ScopeDeclaration: foldDeclaration,
69 BlockComment: foldBlockComment,
70 }),
71 ],
72});
73
74const problemLanguage = LRLanguage.define({
75 parser: parserWithMetadata,
76 languageData: {
77 commentTokens: {
78 block: {
79 open: '/*',
80 close: '*/',
81 },
82 line: '%',
83 },
84 indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.|~>)$/,
85 },
86});
87
88export function problemLanguageSupport(): LanguageSupport {
89 return new LanguageSupport(problemLanguage, [
90 indentUnit.of(' '),
91 ]);
92}
diff --git a/language-web/src/main/js/theme/ThemeStore.ts b/language-web/src/main/js/theme/ThemeStore.ts
index 3bbea3a1..ffaf6dde 100644
--- a/language-web/src/main/js/theme/ThemeStore.ts
+++ b/language-web/src/main/js/theme/ThemeStore.ts
@@ -42,6 +42,9 @@ export class ThemeStore {
42 secondary: { 42 secondary: {
43 main: themeData.secondary, 43 main: themeData.secondary,
44 }, 44 },
45 error: {
46 main: themeData.secondary,
47 },
45 text: { 48 text: {
46 primary: themeData.foregroundHighlight, 49 primary: themeData.foregroundHighlight,
47 secondary: themeData.foreground, 50 secondary: themeData.foreground,
@@ -51,6 +54,10 @@ export class ThemeStore {
51 return responsiveFontSizes(materialUiTheme); 54 return responsiveFontSizes(materialUiTheme);
52 } 55 }
53 56
57 get darkMode(): boolean {
58 return this.currentThemeData.paletteMode === 'dark';
59 }
60
54 get className(): string { 61 get className(): string {
55 return this.currentThemeData.className; 62 return this.currentThemeData.className;
56 } 63 }
diff --git a/language-web/src/main/js/utils/ConditionVariable.ts b/language-web/src/main/js/utils/ConditionVariable.ts
new file mode 100644
index 00000000..0910dfa6
--- /dev/null
+++ b/language-web/src/main/js/utils/ConditionVariable.ts
@@ -0,0 +1,64 @@
1import { getLogger } from './logger';
2import { PendingTask } from './PendingTask';
3
4const log = getLogger('utils.ConditionVariable');
5
6export type Condition = () => boolean;
7
8export class ConditionVariable {
9 condition: Condition;
10
11 defaultTimeout: number;
12
13 listeners: PendingTask<void>[] = [];
14
15 constructor(condition: Condition, defaultTimeout = 0) {
16 this.condition = condition;
17 this.defaultTimeout = defaultTimeout;
18 }
19
20 async waitFor(timeoutMs: number | null = null): Promise<void> {
21 if (this.condition()) {
22 return;
23 }
24 const timeoutOrDefault = timeoutMs || this.defaultTimeout;
25 let nowMs = Date.now();
26 const endMs = nowMs + timeoutOrDefault;
27 while (!this.condition() && nowMs < endMs) {
28 const remainingMs = endMs - nowMs;
29 const promise = new Promise<void>((resolve, reject) => {
30 if (this.condition()) {
31 resolve();
32 return;
33 }
34 const task = new PendingTask(resolve, reject, remainingMs);
35 this.listeners.push(task);
36 });
37 // We must keep waiting until the update has completed,
38 // so the tasks can't be started in parallel.
39 // eslint-disable-next-line no-await-in-loop
40 await promise;
41 nowMs = Date.now();
42 }
43 if (!this.condition()) {
44 log.error('Condition still does not hold after', timeoutOrDefault, 'ms');
45 throw new Error('Failed to wait for condition');
46 }
47 }
48
49 notifyAll(): void {
50 this.clearListenersWith((listener) => listener.resolve());
51 }
52
53 rejectAll(error: unknown): void {
54 this.clearListenersWith((listener) => listener.reject(error));
55 }
56
57 private clearListenersWith(callback: (listener: PendingTask<void>) => void) {
58 // Copy `listeners` so that we don't get into a race condition
59 // if one of the listeners adds another listener.
60 const { listeners } = this;
61 this.listeners = [];
62 listeners.forEach(callback);
63 }
64}
diff --git a/language-web/src/main/js/utils/PendingTask.ts b/language-web/src/main/js/utils/PendingTask.ts
new file mode 100644
index 00000000..de59a99b
--- /dev/null
+++ b/language-web/src/main/js/utils/PendingTask.ts
@@ -0,0 +1,60 @@
1import { getLogger } from './logger';
2
3const log = getLogger('utils.PendingTask');
4
5export class PendingTask<T> {
6 private readonly resolveCallback: (value: T) => void;
7
8 private readonly rejectCallback: (reason?: unknown) => void;
9
10 private resolved = false;
11
12 private timeout: NodeJS.Timeout | null;
13
14 constructor(
15 resolveCallback: (value: T) => void,
16 rejectCallback: (reason?: unknown) => void,
17 timeoutMs?: number,
18 timeoutCallback?: () => void,
19 ) {
20 this.resolveCallback = resolveCallback;
21 this.rejectCallback = rejectCallback;
22 if (timeoutMs) {
23 this.timeout = setTimeout(() => {
24 if (!this.resolved) {
25 this.reject(new Error('Request timed out'));
26 if (timeoutCallback) {
27 timeoutCallback();
28 }
29 }
30 }, timeoutMs);
31 } else {
32 this.timeout = null;
33 }
34 }
35
36 resolve(value: T): void {
37 if (this.resolved) {
38 log.warn('Trying to resolve already resolved promise');
39 return;
40 }
41 this.markResolved();
42 this.resolveCallback(value);
43 }
44
45 reject(reason?: unknown): void {
46 if (this.resolved) {
47 log.warn('Trying to reject already resolved promise');
48 return;
49 }
50 this.markResolved();
51 this.rejectCallback(reason);
52 }
53
54 private markResolved() {
55 this.resolved = true;
56 if (this.timeout !== null) {
57 clearTimeout(this.timeout);
58 }
59 }
60}
diff --git a/language-web/src/main/js/utils/Timer.ts b/language-web/src/main/js/utils/Timer.ts
new file mode 100644
index 00000000..efde6633
--- /dev/null
+++ b/language-web/src/main/js/utils/Timer.ts
@@ -0,0 +1,33 @@
1export class Timer {
2 readonly callback: () => void;
3
4 readonly defaultTimeout: number;
5
6 timeout: NodeJS.Timeout | null = null;
7
8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => {
10 this.timeout = null;
11 callback();
12 };
13 this.defaultTimeout = defaultTimeout;
14 }
15
16 schedule(timeout: number | null = null): void {
17 if (this.timeout === null) {
18 this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout);
19 }
20 }
21
22 reschedule(timeout: number | null = null): void {
23 this.cancel();
24 this.schedule(timeout);
25 }
26
27 cancel(): void {
28 if (this.timeout !== null) {
29 clearTimeout(this.timeout);
30 this.timeout = null;
31 }
32 }
33}
diff --git a/language-web/src/main/js/logging.tsx b/language-web/src/main/js/utils/logger.ts
index 25f50f19..306d122c 100644
--- a/language-web/src/main/js/logging.tsx
+++ b/language-web/src/main/js/utils/logger.ts
@@ -2,7 +2,7 @@ import styles, { CSPair } from 'ansi-styles';
2import log from 'loglevel'; 2import log from 'loglevel';
3import * as prefix from 'loglevel-plugin-prefix'; 3import * as prefix from 'loglevel-plugin-prefix';
4 4
5const colors: Record<string, CSPair> = { 5const colors: Partial<Record<string, CSPair>> = {
6 TRACE: styles.magenta, 6 TRACE: styles.magenta,
7 DEBUG: styles.cyan, 7 DEBUG: styles.cyan,
8 INFO: styles.blue, 8 INFO: styles.blue,
diff --git a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js b/language-web/src/main/js/xtext/CodeMirrorEditorContext.js
deleted file mode 100644
index b829c680..00000000
--- a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js
+++ /dev/null
@@ -1,111 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define([], function() {
11
12 /**
13 * An editor context mediates between the Xtext services and the CodeMirror editor framework.
14 */
15 function CodeMirrorEditorContext(editor) {
16 this._editor = editor;
17 this._serverState = {};
18 this._serverStateListeners = [];
19 this._dirty = false;
20 this._dirtyStateListeners = [];
21 };
22
23 CodeMirrorEditorContext.prototype = {
24
25 getServerState: function() {
26 return this._serverState;
27 },
28
29 updateServerState: function(currentText, currentStateId) {
30 this._serverState.text = currentText;
31 this._serverState.stateId = currentStateId;
32 return this._serverStateListeners;
33 },
34
35 addServerStateListener: function(listener) {
36 this._serverStateListeners.push(listener);
37 },
38
39 getCaretOffset: function() {
40 var editor = this._editor;
41 return editor.indexFromPos(editor.getCursor());
42 },
43
44 getLineStart: function(lineNumber) {
45 var editor = this._editor;
46 return editor.indexFromPos({line: lineNumber, ch: 0});
47 },
48
49 getSelection: function() {
50 var editor = this._editor;
51 return {
52 start: editor.indexFromPos(editor.getCursor('from')),
53 end: editor.indexFromPos(editor.getCursor('to'))
54 };
55 },
56
57 getText: function(start, end) {
58 var editor = this._editor;
59 if (start && end) {
60 return editor.getRange(editor.posFromIndex(start), editor.posFromIndex(end));
61 } else {
62 return editor.getValue();
63 }
64 },
65
66 isDirty: function() {
67 return !this._clean;
68 },
69
70 setDirty: function(dirty) {
71 if (dirty != this._dirty) {
72 for (var i = 0; i < this._dirtyStateListeners.length; i++) {
73 this._dirtyStateListeners[i](dirty);
74 }
75 }
76 this._dirty = dirty;
77 },
78
79 addDirtyStateListener: function(listener) {
80 this._dirtyStateListeners.push(listener);
81 },
82
83 clearUndoStack: function() {
84 this._editor.clearHistory();
85 },
86
87 setCaretOffset: function(offset) {
88 var editor = this._editor;
89 editor.setCursor(editor.posFromIndex(offset));
90 },
91
92 setSelection: function(selection) {
93 var editor = this._editor;
94 editor.setSelection(editor.posFromIndex(selection.start), editor.posFromIndex(selection.end));
95 },
96
97 setText: function(text, start, end) {
98 var editor = this._editor;
99 if (!start)
100 start = 0;
101 if (!end)
102 end = editor.getValue().length;
103 var cursor = editor.getCursor();
104 editor.replaceRange(text, editor.posFromIndex(start), editor.posFromIndex(end));
105 editor.setCursor(cursor);
106 }
107
108 };
109
110 return CodeMirrorEditorContext;
111}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts
new file mode 100644
index 00000000..f085c5b1
--- /dev/null
+++ b/language-web/src/main/js/xtext/ContentAssistService.ts
@@ -0,0 +1,177 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import type { Transaction } from '@codemirror/state';
7import escapeStringRegexp from 'escape-string-regexp';
8
9import type { UpdateService } from './UpdateService';
10import { getLogger } from '../utils/logger';
11import type { IContentAssistEntry } from './xtextServiceResults';
12
13const PROPOSALS_LIMIT = 1000;
14
15const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
16
17const HIGH_PRIORITY_KEYWORDS = ['<->'];
18
19const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g;
20
21const log = getLogger('xtext.ContentAssistService');
22
23function createCompletion(entry: IContentAssistEntry): Completion {
24 let boost;
25 switch (entry.kind) {
26 case 'KEYWORD':
27 // Some hard-to-type operators should be on top.
28 boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99;
29 break;
30 case 'TEXT':
31 case 'SNIPPET':
32 boost = -90;
33 break;
34 default: {
35 // Penalize qualified names (vs available unqualified names).
36 const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0;
37 boost = Math.max(-5 * extraSegments, -50);
38 }
39 break;
40 }
41 return {
42 label: entry.proposal,
43 detail: entry.description,
44 info: entry.documentation,
45 type: entry.kind?.toLowerCase(),
46 boost,
47 };
48}
49
50function computeSpan(prefix: string, entryCount: number) {
51 const escapedPrefix = escapeStringRegexp(prefix);
52 if (entryCount < PROPOSALS_LIMIT) {
53 // Proposals with the current prefix fit the proposals limit.
54 // We can filter client side as long as the current prefix is preserved.
55 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
56 }
57 // The current prefix overflows the proposals limits,
58 // so we have to fetch the completions again on the next keypress.
59 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
60 return new RegExp(`^${escapedPrefix}$`);
61}
62
63export class ContentAssistService {
64 private readonly updateService: UpdateService;
65
66 private lastCompletion: CompletionResult | null = null;
67
68 constructor(updateService: UpdateService) {
69 this.updateService = updateService;
70 }
71
72 onTransaction(transaction: Transaction): void {
73 if (this.shouldInvalidateCachedCompletion(transaction)) {
74 this.lastCompletion = null;
75 }
76 }
77
78 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
79 const tokenBefore = context.tokenBefore(['QualifiedName']);
80 let range: { from: number, to: number };
81 let prefix = '';
82 if (tokenBefore === null) {
83 if (!context.explicit) {
84 return {
85 from: context.pos,
86 options: [],
87 };
88 }
89 range = {
90 from: context.pos,
91 to: context.pos,
92 };
93 prefix = '';
94 } else {
95 range = {
96 from: tokenBefore.from,
97 to: tokenBefore.to,
98 };
99 const prefixLength = context.pos - tokenBefore.from;
100 if (prefixLength > 0) {
101 prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from);
102 }
103 }
104 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) {
105 log.trace('Returning cached completion result');
106 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
107 return {
108 ...this.lastCompletion as CompletionResult,
109 ...range,
110 };
111 }
112 this.lastCompletion = null;
113 const entries = await this.updateService.fetchContentAssist({
114 resource: this.updateService.resourceName,
115 serviceType: 'assist',
116 caretOffset: context.pos,
117 proposalsLimit: PROPOSALS_LIMIT,
118 }, context);
119 if (context.aborted) {
120 return {
121 ...range,
122 options: [],
123 };
124 }
125 const options: Completion[] = [];
126 entries.forEach((entry) => {
127 options.push(createCompletion(entry));
128 });
129 log.debug('Fetched', options.length, 'completions from server');
130 this.lastCompletion = {
131 ...range,
132 options,
133 span: computeSpan(prefix, entries.length),
134 };
135 return this.lastCompletion;
136 }
137
138 private shouldReturnCachedCompletion(
139 token: { from: number, to: number, text: string } | null,
140 ) {
141 if (token === null || this.lastCompletion === null) {
142 return false;
143 }
144 const { from, to, text } = token;
145 const { from: lastFrom, to: lastTo, span } = this.lastCompletion;
146 if (!lastTo) {
147 return true;
148 }
149 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
150 return from >= transformedFrom && to <= transformedTo && span && span.exec(text);
151 }
152
153 private shouldInvalidateCachedCompletion(transaction: Transaction) {
154 if (!transaction.docChanged || this.lastCompletion === null) {
155 return false;
156 }
157 const { from: lastFrom, to: lastTo } = this.lastCompletion;
158 if (!lastTo) {
159 return true;
160 }
161 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
162 let invalidate = false;
163 transaction.changes.iterChangedRanges((fromA, toA) => {
164 if (fromA < transformedFrom || toA > transformedTo) {
165 invalidate = true;
166 }
167 });
168 return invalidate;
169 }
170
171 private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] {
172 const changes = this.updateService.computeChangesSinceLastUpdate();
173 const transformedFrom = changes.mapPos(lastFrom);
174 const transformedTo = changes.mapPos(lastTo, 1);
175 return [transformedFrom, transformedTo];
176 }
177}
diff --git a/language-web/src/main/js/xtext/HighlightingService.ts b/language-web/src/main/js/xtext/HighlightingService.ts
new file mode 100644
index 00000000..fc3e9e53
--- /dev/null
+++ b/language-web/src/main/js/xtext/HighlightingService.ts
@@ -0,0 +1,43 @@
1import type { EditorStore } from '../editor/EditorStore';
2import type { IHighlightRange } from '../editor/semanticHighlighting';
3import type { UpdateService } from './UpdateService';
4import { getLogger } from '../utils/logger';
5import { isHighlightingResult } from './xtextServiceResults';
6
7const log = getLogger('xtext.ValidationService');
8
9export class HighlightingService {
10 private readonly store: EditorStore;
11
12 private readonly updateService: UpdateService;
13
14 constructor(store: EditorStore, updateService: UpdateService) {
15 this.store = store;
16 this.updateService = updateService;
17 }
18
19 onPush(push: unknown): void {
20 if (!isHighlightingResult(push)) {
21 log.error('Invalid highlighting result', push);
22 return;
23 }
24 const allChanges = this.updateService.computeChangesSinceLastUpdate();
25 const ranges: IHighlightRange[] = [];
26 push.regions.forEach(({ offset, length, styleClasses }) => {
27 if (styleClasses.length === 0) {
28 return;
29 }
30 const from = allChanges.mapPos(offset);
31 const to = allChanges.mapPos(offset + length);
32 if (to <= from) {
33 return;
34 }
35 ranges.push({
36 from,
37 to,
38 classes: styleClasses,
39 });
40 });
41 this.store.updateSemanticHighlighting(ranges);
42 }
43}
diff --git a/language-web/src/main/js/xtext/OccurrencesService.ts b/language-web/src/main/js/xtext/OccurrencesService.ts
new file mode 100644
index 00000000..d1dec9e9
--- /dev/null
+++ b/language-web/src/main/js/xtext/OccurrencesService.ts
@@ -0,0 +1,116 @@
1import { Transaction } from '@codemirror/state';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences';
5import type { UpdateService } from './UpdateService';
6import { getLogger } from '../utils/logger';
7import { Timer } from '../utils/Timer';
8import { XtextWebSocketClient } from './XtextWebSocketClient';
9import {
10 isOccurrencesResult,
11 isServiceConflictResult,
12 ITextRegion,
13} from './xtextServiceResults';
14
15const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
16
17// Must clear occurrences asynchronously from `onTransaction`,
18// because we must not emit a conflicting transaction when handling the pending transaction.
19const CLEAR_OCCURRENCES_TIMEOUT_MS = 10;
20
21const log = getLogger('xtext.OccurrencesService');
22
23function transformOccurrences(regions: ITextRegion[]): IOccurrence[] {
24 const occurrences: IOccurrence[] = [];
25 regions.forEach(({ offset, length }) => {
26 if (length > 0) {
27 occurrences.push({
28 from: offset,
29 to: offset + length,
30 });
31 }
32 });
33 return occurrences;
34}
35
36export class OccurrencesService {
37 private readonly store: EditorStore;
38
39 private readonly webSocketClient: XtextWebSocketClient;
40
41 private readonly updateService: UpdateService;
42
43 private hasOccurrences = false;
44
45 private readonly findOccurrencesTimer = new Timer(() => {
46 this.handleFindOccurrences();
47 }, FIND_OCCURRENCES_TIMEOUT_MS);
48
49 private readonly clearOccurrencesTimer = new Timer(() => {
50 this.clearOccurrences();
51 }, CLEAR_OCCURRENCES_TIMEOUT_MS);
52
53 constructor(
54 store: EditorStore,
55 webSocketClient: XtextWebSocketClient,
56 updateService: UpdateService,
57 ) {
58 this.store = store;
59 this.webSocketClient = webSocketClient;
60 this.updateService = updateService;
61 }
62
63 onTransaction(transaction: Transaction): void {
64 if (transaction.docChanged) {
65 this.clearOccurrencesTimer.schedule();
66 this.findOccurrencesTimer.reschedule();
67 }
68 if (transaction.isUserEvent('select')) {
69 this.findOccurrencesTimer.reschedule();
70 }
71 }
72
73 private handleFindOccurrences() {
74 this.clearOccurrencesTimer.cancel();
75 this.updateOccurrences().catch((error) => {
76 log.error('Unexpected error while updating occurrences', error);
77 this.clearOccurrences();
78 });
79 }
80
81 private async updateOccurrences() {
82 await this.updateService.update();
83 const result = await this.webSocketClient.send({
84 resource: this.updateService.resourceName,
85 serviceType: 'occurrences',
86 expectedStateId: this.updateService.xtextStateId,
87 caretOffset: this.store.state.selection.main.head,
88 });
89 const allChanges = this.updateService.computeChangesSinceLastUpdate();
90 if (!allChanges.empty
91 || (isServiceConflictResult(result) && result.conflict === 'canceled')) {
92 // Stale occurrences result, the user already made some changes.
93 // We can safely ignore the occurrences and schedule a new find occurrences call.
94 this.clearOccurrences();
95 this.findOccurrencesTimer.schedule();
96 return;
97 }
98 if (!isOccurrencesResult(result) || result.stateId !== this.updateService.xtextStateId) {
99 log.error('Unexpected occurrences result', result);
100 this.clearOccurrences();
101 return;
102 }
103 const write = transformOccurrences(result.writeRegions);
104 const read = transformOccurrences(result.readRegions);
105 this.hasOccurrences = write.length > 0 || read.length > 0;
106 log.debug('Found', write.length, 'write and', read.length, 'read occurrences');
107 this.store.updateOccurrences(write, read);
108 }
109
110 private clearOccurrences() {
111 if (this.hasOccurrences) {
112 this.store.updateOccurrences([], []);
113 this.hasOccurrences = false;
114 }
115 }
116}
diff --git a/language-web/src/main/js/xtext/ServiceBuilder.js b/language-web/src/main/js/xtext/ServiceBuilder.js
deleted file mode 100644
index 57fcb310..00000000
--- a/language-web/src/main/js/xtext/ServiceBuilder.js
+++ /dev/null
@@ -1,285 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 ******************************************************************************/
9
10define([
11 'jquery',
12 'xtext/services/XtextService',
13 'xtext/services/LoadResourceService',
14 'xtext/services/SaveResourceService',
15 'xtext/services/HighlightingService',
16 'xtext/services/ValidationService',
17 'xtext/services/UpdateService',
18 'xtext/services/ContentAssistService',
19 'xtext/services/HoverService',
20 'xtext/services/OccurrencesService',
21 'xtext/services/FormattingService',
22 '../logging',
23], function(jQuery, XtextService, LoadResourceService, SaveResourceService, HighlightingService,
24 ValidationService, UpdateService, ContentAssistService, HoverService, OccurrencesService,
25 FormattingService, logging) {
26
27 /**
28 * Builder class for the Xtext services.
29 */
30 function ServiceBuilder(xtextServices) {
31 this.services = xtextServices;
32 };
33
34 /**
35 * Create all the available Xtext services depending on the configuration.
36 */
37 ServiceBuilder.prototype.createServices = function() {
38 var services = this.services;
39 var options = services.options;
40 var editorContext = services.editorContext;
41 editorContext.xtextServices = services;
42 var self = this;
43 if (!options.serviceUrl) {
44 if (!options.baseUrl)
45 options.baseUrl = '/';
46 else if (options.baseUrl.charAt(0) != '/')
47 options.baseUrl = '/' + options.baseUrl;
48 options.serviceUrl = window.location.protocol + '//' + window.location.host + options.baseUrl + 'xtext-service';
49 }
50 if (options.resourceId) {
51 if (!options.xtextLang)
52 options.xtextLang = options.resourceId.split(/[?#]/)[0].split('.').pop();
53 if (options.loadFromServer === undefined)
54 options.loadFromServer = true;
55 if (options.loadFromServer && this.setupPersistenceServices) {
56 services.loadResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, false);
57 services.loadResource = function(addParams) {
58 return services.loadResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
59 }
60 services.saveResourceService = new SaveResourceService(options.serviceUrl, options.resourceId);
61 services.saveResource = function(addParams) {
62 return services.saveResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
63 }
64 services.revertResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, true);
65 services.revertResource = function(addParams) {
66 return services.revertResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
67 }
68 this.setupPersistenceServices();
69 services.loadResource();
70 }
71 } else {
72 if (options.loadFromServer === undefined)
73 options.loadFromServer = false;
74 if (options.xtextLang) {
75 var randomId = Math.floor(Math.random() * 2147483648).toString(16);
76 options.resourceId = randomId + '.' + options.xtextLang;
77 }
78 }
79
80 if (this.setupSyntaxHighlighting) {
81 this.setupSyntaxHighlighting();
82 }
83 if (options.enableHighlightingService || options.enableHighlightingService === undefined) {
84 services.highlightingService = new HighlightingService(options.serviceUrl, options.resourceId);
85 services.computeHighlighting = function(addParams) {
86 return services.highlightingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
87 }
88 }
89 if (options.enableValidationService || options.enableValidationService === undefined) {
90 services.validationService = new ValidationService(options.serviceUrl, options.resourceId);
91 services.validate = function(addParams) {
92 return services.validationService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
93 }
94 }
95 if (this.setupUpdateService) {
96 function refreshDocument() {
97 if (services.highlightingService && self.doHighlighting) {
98 services.highlightingService.setState(undefined);
99 self.doHighlighting();
100 }
101 if (services.validationService && self.doValidation) {
102 services.validationService.setState(undefined);
103 self.doValidation();
104 }
105 }
106 if (!options.sendFullText) {
107 services.updateService = new UpdateService(options.serviceUrl, options.resourceId);
108 services.update = function(addParams) {
109 return services.updateService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
110 }
111 if (services.saveResourceService)
112 services.saveResourceService._updateService = services.updateService;
113 editorContext.addServerStateListener(refreshDocument);
114 }
115 this.setupUpdateService(refreshDocument);
116 }
117 if ((options.enableContentAssistService || options.enableContentAssistService === undefined)
118 && this.setupContentAssistService) {
119 services.contentAssistService = new ContentAssistService(options.serviceUrl, options.resourceId, services.updateService);
120 services.getContentAssist = function(addParams) {
121 return services.contentAssistService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
122 }
123 this.setupContentAssistService();
124 }
125 if ((options.enableHoverService || options.enableHoverService === undefined)
126 && this.setupHoverService) {
127 services.hoverService = new HoverService(options.serviceUrl, options.resourceId, services.updateService);
128 services.getHoverInfo = function(addParams) {
129 return services.hoverService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
130 }
131 this.setupHoverService();
132 }
133 if ((options.enableOccurrencesService || options.enableOccurrencesService === undefined)
134 && this.setupOccurrencesService) {
135 services.occurrencesService = new OccurrencesService(options.serviceUrl, options.resourceId, services.updateService);
136 services.getOccurrences = function(addParams) {
137 return services.occurrencesService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
138 }
139 this.setupOccurrencesService();
140 }
141 if ((options.enableFormattingService || options.enableFormattingService === undefined)
142 && this.setupFormattingService) {
143 services.formattingService = new FormattingService(options.serviceUrl, options.resourceId, services.updateService);
144 services.format = function(addParams) {
145 return services.formattingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
146 }
147 this.setupFormattingService();
148 }
149 if (options.enableGeneratorService || options.enableGeneratorService === undefined) {
150 services.generatorService = new XtextService();
151 services.generatorService.initialize(services, 'generate');
152 services.generatorService._initServerData = function(serverData, editorContext, params) {
153 if (params.allArtifacts)
154 serverData.allArtifacts = params.allArtifacts;
155 else if (params.artifactId)
156 serverData.artifact = params.artifactId;
157 if (params.includeContent !== undefined)
158 serverData.includeContent = params.includeContent;
159 }
160 services.generate = function(addParams) {
161 return services.generatorService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
162 }
163 }
164
165 if (options.dirtyElement) {
166 var doc = options.document || document;
167 var dirtyElement;
168 if (typeof(options.dirtyElement) === 'string')
169 dirtyElement = jQuery('#' + options.dirtyElement, doc);
170 else
171 dirtyElement = jQuery(options.dirtyElement);
172 var dirtyStatusClass = options.dirtyStatusClass;
173 if (!dirtyStatusClass)
174 dirtyStatusClass = 'dirty';
175 editorContext.addDirtyStateListener(function(dirty) {
176 if (dirty)
177 dirtyElement.addClass(dirtyStatusClass);
178 else
179 dirtyElement.removeClass(dirtyStatusClass);
180 });
181 }
182
183 const log = logging.getLoggerFromRoot('xtext.XtextService');
184 services.successListeners = [function(serviceType, result) {
185 if (log.getLevel() <= log.levels.TRACE) {
186 log.trace('service', serviceType, 'request success', JSON.parse(JSON.stringify(result)));
187 }
188 }];
189 services.errorListeners = [function(serviceType, severity, message, requestData) {
190 const messageParts = ['service', serviceType, 'failed:', message || '(no message)'];
191 if (requestData) {
192 messageParts.push(JSON.parse(JSON.stringify(requestData)));
193 }
194 if (severity === 'warning') {
195 log.warn(...messageParts);
196 } else {
197 log.error(...messageParts);
198 }
199 }];
200 }
201
202 /**
203 * Change the resource associated with this service builder.
204 */
205 ServiceBuilder.prototype.changeResource = function(resourceId) {
206 var services = this.services;
207 var options = services.options;
208 options.resourceId = resourceId;
209 for (var p in services) {
210 if (services.hasOwnProperty(p)) {
211 var service = services[p];
212 if (service._serviceType && jQuery.isFunction(service.initialize))
213 services[p].initialize(options.serviceUrl, service._serviceType, resourceId, services.updateService);
214 }
215 }
216 var knownServerState = services.editorContext.getServerState();
217 delete knownServerState.stateId;
218 delete knownServerState.text;
219 if (options.loadFromServer && jQuery.isFunction(services.loadResource)) {
220 services.loadResource();
221 }
222 }
223
224 /**
225 * Create a copy of the given object.
226 */
227 ServiceBuilder.copy = function(obj) {
228 var copy = {};
229 for (var p in obj) {
230 if (obj.hasOwnProperty(p))
231 copy[p] = obj[p];
232 }
233 return copy;
234 }
235
236 /**
237 * Translate an HTML attribute name to a JS option name.
238 */
239 ServiceBuilder.optionName = function(name) {
240 var prefix = 'data-editor-';
241 if (name.substring(0, prefix.length) === prefix) {
242 var key = name.substring(prefix.length);
243 key = key.replace(/-([a-z])/ig, function(all, character) {
244 return character.toUpperCase();
245 });
246 return key;
247 }
248 return undefined;
249 }
250
251 /**
252 * Copy all default options into the given set of additional options.
253 */
254 ServiceBuilder.mergeOptions = function(options, defaultOptions) {
255 if (options) {
256 for (var p in defaultOptions) {
257 if (defaultOptions.hasOwnProperty(p))
258 options[p] = defaultOptions[p];
259 }
260 return options;
261 } else {
262 return ServiceBuilder.copy(defaultOptions);
263 }
264 }
265
266 /**
267 * Merge all properties of the given parent element with the given default options.
268 */
269 ServiceBuilder.mergeParentOptions = function(parent, defaultOptions) {
270 var options = ServiceBuilder.copy(defaultOptions);
271 for (var attr, j = 0, attrs = parent.attributes, l = attrs.length; j < l; j++) {
272 attr = attrs.item(j);
273 var key = ServiceBuilder.optionName(attr.nodeName);
274 if (key) {
275 var value = attr.nodeValue;
276 if (value === 'true' || value === 'false')
277 value = value === 'true';
278 options[key] = value;
279 }
280 }
281 return options;
282 }
283
284 return ServiceBuilder;
285});
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts
new file mode 100644
index 00000000..9b672e79
--- /dev/null
+++ b/language-web/src/main/js/xtext/UpdateService.ts
@@ -0,0 +1,310 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 Transaction,
5} from '@codemirror/state';
6import { nanoid } from 'nanoid';
7
8import type { EditorStore } from '../editor/EditorStore';
9import type { XtextWebSocketClient } from './XtextWebSocketClient';
10import { ConditionVariable } from '../utils/ConditionVariable';
11import { getLogger } from '../utils/logger';
12import { Timer } from '../utils/Timer';
13import {
14 IContentAssistEntry,
15 isContentAssistResult,
16 isDocumentStateResult,
17 isInvalidStateIdConflictResult,
18} from './xtextServiceResults';
19
20const UPDATE_TIMEOUT_MS = 500;
21
22const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
23
24const log = getLogger('xtext.UpdateService');
25
26export interface IAbortSignal {
27 aborted: boolean;
28}
29
30export class UpdateService {
31 resourceName: string;
32
33 xtextStateId: string | null = null;
34
35 private readonly store: EditorStore;
36
37 /**
38 * The changes being synchronized to the server if a full or delta text update is running,
39 * `null` otherwise.
40 */
41 private pendingUpdate: ChangeDesc | null = null;
42
43 /**
44 * Local changes not yet sychronized to the server and not part of the running update, if any.
45 */
46 private dirtyChanges: ChangeDesc;
47
48 private readonly webSocketClient: XtextWebSocketClient;
49
50 private readonly updatedCondition = new ConditionVariable(
51 () => this.pendingUpdate === null && this.xtextStateId !== null,
52 WAIT_FOR_UPDATE_TIMEOUT_MS,
53 );
54
55 private readonly idleUpdateTimer = new Timer(() => {
56 this.handleIdleUpdate();
57 }, UPDATE_TIMEOUT_MS);
58
59 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
60 this.resourceName = `${nanoid(7)}.problem`;
61 this.store = store;
62 this.dirtyChanges = this.newEmptyChangeDesc();
63 this.webSocketClient = webSocketClient;
64 }
65
66 onReconnect(): void {
67 this.xtextStateId = null;
68 this.updateFullText().catch((error) => {
69 log.error('Unexpected error during initial update', error);
70 });
71 }
72
73 onTransaction(transaction: Transaction): void {
74 if (transaction.docChanged) {
75 this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc);
76 this.idleUpdateTimer.reschedule();
77 }
78 }
79
80 /**
81 * Computes the summary of any changes happened since the last complete update.
82 *
83 * The result reflects any changes that happened since the `xtextStateId`
84 * version was uploaded to the server.
85 *
86 * @return the summary of changes since the last update
87 */
88 computeChangesSinceLastUpdate(): ChangeDesc {
89 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges;
90 }
91
92 private handleIdleUpdate() {
93 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
94 return;
95 }
96 if (this.pendingUpdate === null) {
97 this.update().catch((error) => {
98 log.error('Unexpected error during scheduled update', error);
99 });
100 }
101 this.idleUpdateTimer.reschedule();
102 }
103
104 private newEmptyChangeDesc() {
105 const changeSet = ChangeSet.of([], this.store.state.doc.length);
106 return changeSet.desc;
107 }
108
109 async updateFullText(): Promise<void> {
110 await this.withUpdate(() => this.doUpdateFullText());
111 }
112
113 private async doUpdateFullText(): Promise<[string, void]> {
114 const result = await this.webSocketClient.send({
115 resource: this.resourceName,
116 serviceType: 'update',
117 fullText: this.store.state.doc.sliceString(0),
118 });
119 if (isDocumentStateResult(result)) {
120 return [result.stateId, undefined];
121 }
122 log.error('Unexpected full text update result:', result);
123 throw new Error('Full text update failed');
124 }
125
126 /**
127 * Makes sure that the document state on the server reflects recent
128 * local changes.
129 *
130 * Performs either an update with delta text or a full text update if needed.
131 * If there are not local dirty changes, the promise resolves immediately.
132 *
133 * @return a promise resolving when the update is completed
134 */
135 async update(): Promise<void> {
136 await this.prepareForDeltaUpdate();
137 const delta = this.computeDelta();
138 if (delta === null) {
139 return;
140 }
141 log.trace('Editor delta', delta);
142 await this.withUpdate(async () => {
143 const result = await this.webSocketClient.send({
144 resource: this.resourceName,
145 serviceType: 'update',
146 requiredStateId: this.xtextStateId,
147 ...delta,
148 });
149 if (isDocumentStateResult(result)) {
150 return [result.stateId, undefined];
151 }
152 if (isInvalidStateIdConflictResult(result)) {
153 return this.doFallbackToUpdateFullText();
154 }
155 log.error('Unexpected delta text update result:', result);
156 throw new Error('Delta text update failed');
157 });
158 }
159
160 private doFallbackToUpdateFullText() {
161 if (this.pendingUpdate === null) {
162 throw new Error('Only a pending update can be extended');
163 }
164 log.warn('Delta update failed, performing full text update');
165 this.xtextStateId = null;
166 this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges);
167 this.dirtyChanges = this.newEmptyChangeDesc();
168 return this.doUpdateFullText();
169 }
170
171 async fetchContentAssist(
172 params: Record<string, unknown>,
173 signal: IAbortSignal,
174 ): Promise<IContentAssistEntry[]> {
175 await this.prepareForDeltaUpdate();
176 if (signal.aborted) {
177 return [];
178 }
179 const delta = this.computeDelta();
180 if (delta !== null) {
181 log.trace('Editor delta', delta);
182 const entries = await this.withUpdate(async () => {
183 const result = await this.webSocketClient.send({
184 ...params,
185 requiredStateId: this.xtextStateId,
186 ...delta,
187 });
188 if (isContentAssistResult(result)) {
189 return [result.stateId, result.entries];
190 }
191 if (isInvalidStateIdConflictResult(result)) {
192 const [newStateId] = await this.doFallbackToUpdateFullText();
193 // We must finish this state update transaction to prepare for any push events
194 // before querying for content assist, so we just return `null` and will query
195 // the content assist service later.
196 return [newStateId, null];
197 }
198 log.error('Unextpected content assist result with delta update', result);
199 throw new Error('Unexpexted content assist result with delta update');
200 });
201 if (entries !== null) {
202 return entries;
203 }
204 if (signal.aborted) {
205 return [];
206 }
207 }
208 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
209 return this.doFetchContentAssist(params, this.xtextStateId as string);
210 }
211
212 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) {
213 const result = await this.webSocketClient.send({
214 ...params,
215 requiredStateId: expectedStateId,
216 });
217 if (isContentAssistResult(result) && result.stateId === expectedStateId) {
218 return result.entries;
219 }
220 log.error('Unexpected content assist result', result);
221 throw new Error('Unexpected content assist result');
222 }
223
224 private computeDelta() {
225 if (this.dirtyChanges.empty) {
226 return null;
227 }
228 let minFromA = Number.MAX_SAFE_INTEGER;
229 let maxToA = 0;
230 let minFromB = Number.MAX_SAFE_INTEGER;
231 let maxToB = 0;
232 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
233 minFromA = Math.min(minFromA, fromA);
234 maxToA = Math.max(maxToA, toA);
235 minFromB = Math.min(minFromB, fromB);
236 maxToB = Math.max(maxToB, toB);
237 });
238 return {
239 deltaOffset: minFromA,
240 deltaReplaceLength: maxToA - minFromA,
241 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
242 };
243 }
244
245 /**
246 * Executes an asynchronous callback that updates the state on the server.
247 *
248 * Ensures that updates happen sequentially and manages `pendingUpdate`
249 * and `dirtyChanges` to reflect changes being synchronized to the server
250 * and not yet synchronized to the server, respectively.
251 *
252 * Optionally, `callback` may return a second value that is retured by this function.
253 *
254 * Once the remote procedure call to update the server state finishes
255 * and returns the new `stateId`, `callback` must return _immediately_
256 * to ensure that the local `stateId` is updated likewise to be able to handle
257 * push messages referring to the new `stateId` from the server.
258 * If additional work is needed to compute the second value in some cases,
259 * use `T | null` instead of `T` as a return type and signal the need for additional
260 * computations by returning `null`. Thus additional computations can be performed
261 * outside of the critical section.
262 *
263 * @param callback the asynchronous callback that updates the server state
264 * @return a promise resolving to the second value returned by `callback`
265 */
266 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
267 if (this.pendingUpdate !== null) {
268 throw new Error('Another update is pending, will not perform update');
269 }
270 this.pendingUpdate = this.dirtyChanges;
271 this.dirtyChanges = this.newEmptyChangeDesc();
272 let newStateId: string | null = null;
273 try {
274 let result: T;
275 [newStateId, result] = await callback();
276 this.xtextStateId = newStateId;
277 this.pendingUpdate = null;
278 this.updatedCondition.notifyAll();
279 return result;
280 } catch (e) {
281 log.error('Error while update', e);
282 if (this.pendingUpdate === null) {
283 log.error('pendingUpdate was cleared during update');
284 } else {
285 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
286 }
287 this.pendingUpdate = null;
288 this.webSocketClient.forceReconnectOnError();
289 this.updatedCondition.rejectAll(e);
290 throw e;
291 }
292 }
293
294 /**
295 * Ensures that there is some state available on the server (`xtextStateId`)
296 * and that there is not pending update.
297 *
298 * After this function resolves, a delta text update is possible.
299 *
300 * @return a promise resolving when there is a valid state id but no pending update
301 */
302 private async prepareForDeltaUpdate() {
303 // If no update is pending, but the full text hasn't been uploaded to the server yet,
304 // we must start a full text upload.
305 if (this.pendingUpdate === null && this.xtextStateId === null) {
306 await this.updateFullText();
307 }
308 await this.updatedCondition.waitFor();
309 }
310}
diff --git a/language-web/src/main/js/xtext/ValidationService.ts b/language-web/src/main/js/xtext/ValidationService.ts
new file mode 100644
index 00000000..8e4934ac
--- /dev/null
+++ b/language-web/src/main/js/xtext/ValidationService.ts
@@ -0,0 +1,45 @@
1import type { Diagnostic } from '@codemirror/lint';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { UpdateService } from './UpdateService';
5import { getLogger } from '../utils/logger';
6import { isValidationResult } from './xtextServiceResults';
7
8const log = getLogger('xtext.ValidationService');
9
10export class ValidationService {
11 private readonly store: EditorStore;
12
13 private readonly updateService: UpdateService;
14
15 constructor(store: EditorStore, updateService: UpdateService) {
16 this.store = store;
17 this.updateService = updateService;
18 }
19
20 onPush(push: unknown): void {
21 if (!isValidationResult(push)) {
22 log.error('Invalid validation result', push);
23 return;
24 }
25 const allChanges = this.updateService.computeChangesSinceLastUpdate();
26 const diagnostics: Diagnostic[] = [];
27 push.issues.forEach(({
28 offset,
29 length,
30 severity,
31 description,
32 }) => {
33 if (severity === 'ignore') {
34 return;
35 }
36 diagnostics.push({
37 from: allChanges.mapPos(offset),
38 to: allChanges.mapPos(offset + length),
39 severity,
40 message: description,
41 });
42 });
43 this.store.updateDiagnostics(diagnostics);
44 }
45}
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts
new file mode 100644
index 00000000..28f3d0cc
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextClient.ts
@@ -0,0 +1,83 @@
1import type {
2 CompletionContext,
3 CompletionResult,
4} from '@codemirror/autocomplete';
5import type { Transaction } from '@codemirror/state';
6
7import type { EditorStore } from '../editor/EditorStore';
8import { ContentAssistService } from './ContentAssistService';
9import { HighlightingService } from './HighlightingService';
10import { OccurrencesService } from './OccurrencesService';
11import { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import { ValidationService } from './ValidationService';
14import { XtextWebSocketClient } from './XtextWebSocketClient';
15
16const log = getLogger('xtext.XtextClient');
17
18export class XtextClient {
19 private readonly webSocketClient: XtextWebSocketClient;
20
21 private readonly updateService: UpdateService;
22
23 private readonly contentAssistService: ContentAssistService;
24
25 private readonly highlightingService: HighlightingService;
26
27 private readonly validationService: ValidationService;
28
29 private readonly occurrencesService: OccurrencesService;
30
31 constructor(store: EditorStore) {
32 this.webSocketClient = new XtextWebSocketClient(
33 () => this.updateService.onReconnect(),
34 (resource, stateId, service, push) => this.onPush(resource, stateId, service, push),
35 );
36 this.updateService = new UpdateService(store, this.webSocketClient);
37 this.contentAssistService = new ContentAssistService(this.updateService);
38 this.highlightingService = new HighlightingService(store, this.updateService);
39 this.validationService = new ValidationService(store, this.updateService);
40 this.occurrencesService = new OccurrencesService(
41 store,
42 this.webSocketClient,
43 this.updateService,
44 );
45 }
46
47 onTransaction(transaction: Transaction): void {
48 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc
49 // _before_ the current edit, so we call it before `updateService`.
50 this.contentAssistService.onTransaction(transaction);
51 this.updateService.onTransaction(transaction);
52 this.occurrencesService.onTransaction(transaction);
53 }
54
55 private onPush(resource: string, stateId: string, service: string, push: unknown) {
56 const { resourceName, xtextStateId } = this.updateService;
57 if (resource !== resourceName) {
58 log.error('Unknown resource name: expected:', resourceName, 'got:', resource);
59 return;
60 }
61 if (stateId !== xtextStateId) {
62 log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId);
63 // The current push message might be stale (referring to a previous state),
64 // so this is not neccessarily an error and there is no need to force-reconnect.
65 return;
66 }
67 switch (service) {
68 case 'highlight':
69 this.highlightingService.onPush(push);
70 return;
71 case 'validate':
72 this.validationService.onPush(push);
73 return;
74 default:
75 log.error('Unknown push service:', service);
76 break;
77 }
78 }
79
80 contentAssist(context: CompletionContext): Promise<CompletionResult> {
81 return this.contentAssistService.contentAssist(context);
82 }
83}
diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
new file mode 100644
index 00000000..488e4b3b
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,341 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 isErrorResponse,
8 isOkResponse,
9 isPushMessage,
10 IXtextWebRequest,
11} from './xtextMessages';
12import { isPongResult } from './xtextServiceResults';
13
14const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
15
16const WEBSOCKET_CLOSE_OK = 1000;
17
18const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
19
20const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
21
22const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
23
24const PING_TIMEOUT_MS = 10 * 1000;
25
26const REQUEST_TIMEOUT_MS = 1000;
27
28const log = getLogger('xtext.XtextWebSocketClient');
29
30export type ReconnectHandler = () => void;
31
32export type PushHandler = (
33 resourceId: string,
34 stateId: string,
35 service: string,
36 data: unknown,
37) => void;
38
39enum State {
40 Initial,
41 Opening,
42 TabVisible,
43 TabHiddenIdle,
44 TabHiddenWaiting,
45 Error,
46 TimedOut,
47}
48
49export class XtextWebSocketClient {
50 private nextMessageId = 0;
51
52 private connection!: WebSocket;
53
54 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
55
56 private readonly onReconnect: ReconnectHandler;
57
58 private readonly onPush: PushHandler;
59
60 private state = State.Initial;
61
62 private reconnectTryCount = 0;
63
64 private readonly idleTimer = new Timer(() => {
65 this.handleIdleTimeout();
66 }, BACKGROUND_IDLE_TIMEOUT_MS);
67
68 private readonly pingTimer = new Timer(() => {
69 this.sendPing();
70 }, PING_TIMEOUT_MS);
71
72 private readonly reconnectTimer = new Timer(() => {
73 this.handleReconnect();
74 });
75
76 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
77 this.onReconnect = onReconnect;
78 this.onPush = onPush;
79 document.addEventListener('visibilitychange', () => {
80 this.handleVisibilityChange();
81 });
82 this.reconnect();
83 }
84
85 private get isLogicallyClosed(): boolean {
86 return this.state === State.Error || this.state === State.TimedOut;
87 }
88
89 get isOpen(): boolean {
90 return this.state === State.TabVisible
91 || this.state === State.TabHiddenIdle
92 || this.state === State.TabHiddenWaiting;
93 }
94
95 private reconnect() {
96 if (this.isOpen || this.state === State.Opening) {
97 log.error('Trying to reconnect from', this.state);
98 return;
99 }
100 this.state = State.Opening;
101 const webSocketServer = window.origin.replace(/^http/, 'ws');
102 const webSocketUrl = `${webSocketServer}/xtext-service`;
103 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
104 this.connection.addEventListener('open', () => {
105 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
106 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
107 this.forceReconnectOnError();
108 }
109 if (document.visibilityState === 'hidden') {
110 this.handleTabHidden();
111 } else {
112 this.handleTabVisibleConnected();
113 }
114 log.info('Connected to websocket');
115 this.nextMessageId = 0;
116 this.reconnectTryCount = 0;
117 this.pingTimer.schedule();
118 this.onReconnect();
119 });
120 this.connection.addEventListener('error', (event) => {
121 log.error('Unexpected websocket error', event);
122 this.forceReconnectOnError();
123 });
124 this.connection.addEventListener('message', (event) => {
125 this.handleMessage(event.data);
126 });
127 this.connection.addEventListener('close', (event) => {
128 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
129 && this.pendingRequests.size === 0) {
130 log.info('Websocket closed');
131 return;
132 }
133 log.error('Websocket closed unexpectedly', event.code, event.reason);
134 this.forceReconnectOnError();
135 });
136 }
137
138 private handleVisibilityChange() {
139 if (document.visibilityState === 'hidden') {
140 if (this.state === State.TabVisible) {
141 this.handleTabHidden();
142 }
143 return;
144 }
145 this.idleTimer.cancel();
146 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
147 this.handleTabVisibleConnected();
148 return;
149 }
150 if (this.state === State.TimedOut) {
151 this.reconnect();
152 }
153 }
154
155 private handleTabHidden() {
156 log.debug('Tab hidden while websocket is connected');
157 this.state = State.TabHiddenIdle;
158 this.idleTimer.schedule();
159 }
160
161 private handleTabVisibleConnected() {
162 log.debug('Tab visible while websocket is connected');
163 this.state = State.TabVisible;
164 }
165
166 private handleIdleTimeout() {
167 log.trace('Waiting for pending tasks before disconnect');
168 if (this.state === State.TabHiddenIdle) {
169 this.state = State.TabHiddenWaiting;
170 this.handleWaitingForDisconnect();
171 }
172 }
173
174 private handleWaitingForDisconnect() {
175 if (this.state !== State.TabHiddenWaiting) {
176 return;
177 }
178 const pending = this.pendingRequests.size;
179 if (pending === 0) {
180 log.info('Closing idle websocket');
181 this.state = State.TimedOut;
182 this.closeConnection(1000, 'idle timeout');
183 return;
184 }
185 log.info('Waiting for', pending, 'pending requests before closing websocket');
186 }
187
188 private sendPing() {
189 if (!this.isOpen) {
190 return;
191 }
192 const ping = nanoid();
193 log.trace('Ping', ping);
194 this.send({ ping }).then((result) => {
195 if (isPongResult(result) && result.pong === ping) {
196 log.trace('Pong', ping);
197 this.pingTimer.schedule();
198 } else {
199 log.error('Invalid pong');
200 this.forceReconnectOnError();
201 }
202 }).catch((error) => {
203 log.error('Error while waiting for ping', error);
204 this.forceReconnectOnError();
205 });
206 }
207
208 send(request: unknown): Promise<unknown> {
209 if (!this.isOpen) {
210 throw new Error('Not open');
211 }
212 const messageId = this.nextMessageId.toString(16);
213 if (messageId in this.pendingRequests) {
214 log.error('Message id wraparound still pending', messageId);
215 this.rejectRequest(messageId, new Error('Message id wraparound'));
216 }
217 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
218 this.nextMessageId = 0;
219 } else {
220 this.nextMessageId += 1;
221 }
222 const message = JSON.stringify({
223 id: messageId,
224 request,
225 } as IXtextWebRequest);
226 log.trace('Sending message', message);
227 return new Promise((resolve, reject) => {
228 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
229 this.removePendingRequest(messageId);
230 });
231 this.pendingRequests.set(messageId, task);
232 this.connection.send(message);
233 });
234 }
235
236 private handleMessage(messageStr: unknown) {
237 if (typeof messageStr !== 'string') {
238 log.error('Unexpected binary message', messageStr);
239 this.forceReconnectOnError();
240 return;
241 }
242 log.trace('Incoming websocket message', messageStr);
243 let message: unknown;
244 try {
245 message = JSON.parse(messageStr);
246 } catch (error) {
247 log.error('Json parse error', error);
248 this.forceReconnectOnError();
249 return;
250 }
251 if (isOkResponse(message)) {
252 this.resolveRequest(message.id, message.response);
253 } else if (isErrorResponse(message)) {
254 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
255 if (message.error === 'server') {
256 log.error('Reconnecting due to server error: ', message.message);
257 this.forceReconnectOnError();
258 }
259 } else if (isPushMessage(message)) {
260 this.onPush(
261 message.resource,
262 message.stateId,
263 message.service,
264 message.push,
265 );
266 } else {
267 log.error('Unexpected websocket message', message);
268 this.forceReconnectOnError();
269 }
270 }
271
272 private resolveRequest(messageId: string, value: unknown) {
273 const pendingRequest = this.pendingRequests.get(messageId);
274 if (pendingRequest) {
275 pendingRequest.resolve(value);
276 this.removePendingRequest(messageId);
277 return;
278 }
279 log.error('Trying to resolve unknown request', messageId, 'with', value);
280 }
281
282 private rejectRequest(messageId: string, reason?: unknown) {
283 const pendingRequest = this.pendingRequests.get(messageId);
284 if (pendingRequest) {
285 pendingRequest.reject(reason);
286 this.removePendingRequest(messageId);
287 return;
288 }
289 log.error('Trying to reject unknown request', messageId, 'with', reason);
290 }
291
292 private removePendingRequest(messageId: string) {
293 this.pendingRequests.delete(messageId);
294 this.handleWaitingForDisconnect();
295 }
296
297 forceReconnectOnError(): void {
298 if (this.isLogicallyClosed) {
299 return;
300 }
301 this.abortPendingRequests();
302 this.closeConnection(1000, 'reconnecting due to error');
303 log.error('Reconnecting after delay due to error');
304 this.handleErrorState();
305 }
306
307 private abortPendingRequests() {
308 this.pendingRequests.forEach((request) => {
309 request.reject(new Error('Websocket disconnect'));
310 });
311 this.pendingRequests.clear();
312 }
313
314 private closeConnection(code: number, reason: string) {
315 this.pingTimer.cancel();
316 const { readyState } = this.connection;
317 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
318 this.connection.close(code, reason);
319 }
320 }
321
322 private handleErrorState() {
323 this.state = State.Error;
324 this.reconnectTryCount += 1;
325 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
326 log.info('Reconnecting in', delay, 'ms');
327 this.reconnectTimer.schedule(delay);
328 }
329
330 private handleReconnect() {
331 if (this.state !== State.Error) {
332 log.error('Unexpected reconnect in', this.state);
333 return;
334 }
335 if (document.visibilityState === 'hidden') {
336 this.state = State.TimedOut;
337 } else {
338 this.reconnect();
339 }
340 }
341}
diff --git a/language-web/src/main/js/xtext/compatibility.js b/language-web/src/main/js/xtext/compatibility.js
deleted file mode 100644
index c877fc56..00000000
--- a/language-web/src/main/js/xtext/compatibility.js
+++ /dev/null
@@ -1,63 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define([], function() {
11
12 if (!Function.prototype.bind) {
13 Function.prototype.bind = function(target) {
14 if (typeof this !== 'function')
15 throw new TypeError('bind target is not callable');
16 var args = Array.prototype.slice.call(arguments, 1);
17 var unboundFunc = this;
18 var nopFunc = function() {};
19 boundFunc = function() {
20 var localArgs = Array.prototype.slice.call(arguments);
21 return unboundFunc.apply(this instanceof nopFunc ? this : target,
22 args.concat(localArgs));
23 };
24 nopFunc.prototype = this.prototype;
25 boundFunc.prototype = new nopFunc();
26 return boundFunc;
27 }
28 }
29
30 if (!Array.prototype.map) {
31 Array.prototype.map = function(callback, thisArg) {
32 if (this == null)
33 throw new TypeError('this is null');
34 if (typeof callback !== 'function')
35 throw new TypeError('callback is not callable');
36 var srcArray = Object(this);
37 var len = srcArray.length >>> 0;
38 var tgtArray = new Array(len);
39 for (var i = 0; i < len; i++) {
40 if (i in srcArray)
41 tgtArray[i] = callback.call(thisArg, srcArray[i], i, srcArray);
42 }
43 return tgtArray;
44 }
45 }
46
47 if (!Array.prototype.forEach) {
48 Array.prototype.forEach = function(callback, thisArg) {
49 if (this == null)
50 throw new TypeError('this is null');
51 if (typeof callback !== 'function')
52 throw new TypeError('callback is not callable');
53 var srcArray = Object(this);
54 var len = srcArray.length >>> 0;
55 for (var i = 0; i < len; i++) {
56 if (i in srcArray)
57 callback.call(thisArg, srcArray[i], i, srcArray);
58 }
59 }
60 }
61
62 return {};
63});
diff --git a/language-web/src/main/js/xtext/services/ContentAssistService.js b/language-web/src/main/js/xtext/services/ContentAssistService.js
deleted file mode 100644
index 1686570d..00000000
--- a/language-web/src/main/js/xtext/services/ContentAssistService.js
+++ /dev/null
@@ -1,132 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for content assist proposals. The proposals are returned as promise of
14 * a Deferred object.
15 */
16 function ContentAssistService(serviceUrl, resourceId, updateService) {
17 this.initialize(serviceUrl, 'assist', resourceId, updateService);
18 }
19
20 ContentAssistService.prototype = new XtextService();
21
22 ContentAssistService.prototype.invoke = function(editorContext, params, deferred) {
23 if (deferred === undefined) {
24 deferred = jQuery.Deferred();
25 }
26 var serverData = {
27 contentType: params.contentType
28 };
29 if (params.offset)
30 serverData.caretOffset = params.offset;
31 else
32 serverData.caretOffset = editorContext.getCaretOffset();
33 var selection = params.selection ? params.selection : editorContext.getSelection();
34 if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) {
35 serverData.selectionStart = selection.start;
36 serverData.selectionEnd = selection.end;
37 }
38 var currentText;
39 var httpMethod = 'GET';
40 var onComplete = undefined;
41 var knownServerState = editorContext.getServerState();
42 if (params.sendFullText) {
43 serverData.fullText = editorContext.getText();
44 httpMethod = 'POST';
45 } else {
46 serverData.requiredStateId = knownServerState.stateId;
47 if (this._updateService) {
48 if (knownServerState.text === undefined || knownServerState.updateInProgress) {
49 var self = this;
50 this._updateService.addCompletionCallback(function() {
51 self.invoke(editorContext, params, deferred);
52 });
53 return deferred.promise();
54 }
55 knownServerState.updateInProgress = true;
56 onComplete = this._updateService.onComplete.bind(this._updateService);
57 currentText = editorContext.getText();
58 this._updateService.computeDelta(knownServerState.text, currentText, serverData);
59 if (serverData.deltaText !== undefined) {
60 httpMethod = 'POST';
61 }
62 }
63 }
64
65 var self = this;
66 self.sendRequest(editorContext, {
67 type: httpMethod,
68 data: serverData,
69
70 success: function(result) {
71 if (result.conflict) {
72 // The server has lost its session state and the resource is loaded from the server
73 if (self._increaseRecursionCount(editorContext)) {
74 if (onComplete) {
75 delete knownServerState.updateInProgress;
76 delete knownServerState.text;
77 delete knownServerState.stateId;
78 self._updateService.addCompletionCallback(function() {
79 self.invoke(editorContext, params, deferred);
80 });
81 self._updateService.invoke(editorContext, params);
82 } else {
83 var paramsCopy = {};
84 for (var p in params) {
85 if (params.hasOwnProperty(p))
86 paramsCopy[p] = params[p];
87 }
88 paramsCopy.sendFullText = true;
89 self.invoke(editorContext, paramsCopy, deferred);
90 }
91 } else {
92 deferred.reject(result.conflict);
93 }
94 return false;
95 }
96 if (onComplete && result.stateId !== undefined && result.stateId != editorContext.getServerState().stateId) {
97 var listeners = editorContext.updateServerState(currentText, result.stateId);
98 for (var i = 0; i < listeners.length; i++) {
99 self._updateService.addCompletionCallback(listeners[i], params);
100 }
101 }
102 deferred.resolve(result.entries);
103 },
104
105 error: function(xhr, textStatus, errorThrown) {
106 if (onComplete && xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) {
107 // The server has lost its session state and the resource is not loaded from the server
108 delete knownServerState.updateInProgress;
109 delete knownServerState.text;
110 delete knownServerState.stateId;
111 self._updateService.addCompletionCallback(function() {
112 self.invoke(editorContext, params, deferred);
113 });
114 self._updateService.invoke(editorContext, params);
115 return true;
116 }
117 deferred.reject(errorThrown);
118 },
119
120 complete: onComplete
121 }, !params.sendFullText);
122 var result = deferred.promise();
123 if (onComplete) {
124 result.always(function() {
125 knownServerState.updateInProgress = false;
126 });
127 }
128 return result;
129 };
130
131 return ContentAssistService;
132});
diff --git a/language-web/src/main/js/xtext/services/FormattingService.js b/language-web/src/main/js/xtext/services/FormattingService.js
deleted file mode 100644
index f59099ee..00000000
--- a/language-web/src/main/js/xtext/services/FormattingService.js
+++ /dev/null
@@ -1,52 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for formatting text.
14 */
15 function FormattingService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'format', resourceId, updateService);
17 };
18
19 FormattingService.prototype = new XtextService();
20
21 FormattingService.prototype._initServerData = function(serverData, editorContext, params) {
22 var selection = params.selection ? params.selection : editorContext.getSelection();
23 if (selection.end > selection.start) {
24 serverData.selectionStart = selection.start;
25 serverData.selectionEnd = selection.end;
26 }
27 return {
28 httpMethod: 'POST'
29 };
30 };
31
32 FormattingService.prototype._processResult = function(result, editorContext) {
33 // The text update may be asynchronous, so we have to compute the new text ourselves
34 var newText;
35 if (result.replaceRegion) {
36 var fullText = editorContext.getText();
37 var start = result.replaceRegion.offset;
38 var end = result.replaceRegion.offset + result.replaceRegion.length;
39 editorContext.setText(result.formattedText, start, end);
40 newText = fullText.substring(0, start) + result.formattedText + fullText.substring(end);
41 } else {
42 editorContext.setText(result.formattedText);
43 newText = result.formattedText;
44 }
45 var listeners = editorContext.updateServerState(newText, result.stateId);
46 for (var i = 0; i < listeners.length; i++) {
47 listeners[i]({});
48 }
49 };
50
51 return FormattingService;
52}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/HighlightingService.js b/language-web/src/main/js/xtext/services/HighlightingService.js
deleted file mode 100644
index 5a5ac8ba..00000000
--- a/language-web/src/main/js/xtext/services/HighlightingService.js
+++ /dev/null
@@ -1,33 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for semantic highlighting.
14 */
15 function HighlightingService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'highlight', resourceId);
17 };
18
19 HighlightingService.prototype = new XtextService();
20
21 HighlightingService.prototype._checkPreconditions = function(editorContext, params) {
22 return this._state === undefined;
23 }
24
25 HighlightingService.prototype._onConflict = function(editorContext, cause) {
26 this.setState(undefined);
27 return {
28 suppressForcedUpdate: true
29 };
30 };
31
32 return HighlightingService;
33}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/HoverService.js b/language-web/src/main/js/xtext/services/HoverService.js
deleted file mode 100644
index 03c5a52b..00000000
--- a/language-web/src/main/js/xtext/services/HoverService.js
+++ /dev/null
@@ -1,59 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for hover information.
14 */
15 function HoverService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'hover', resourceId, updateService);
17 };
18
19 HoverService.prototype = new XtextService();
20
21 HoverService.prototype._initServerData = function(serverData, editorContext, params) {
22 // In order to display hover info for a selected completion proposal while the content
23 // assist popup is shown, the selected proposal is passed as parameter
24 if (params.proposal && params.proposal.proposal)
25 serverData.proposal = params.proposal.proposal;
26 if (params.offset)
27 serverData.caretOffset = params.offset;
28 else
29 serverData.caretOffset = editorContext.getCaretOffset();
30 var selection = params.selection ? params.selection : editorContext.getSelection();
31 if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) {
32 serverData.selectionStart = selection.start;
33 serverData.selectionEnd = selection.end;
34 }
35 };
36
37 HoverService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
38 var delay = params.mouseHoverDelay;
39 if (!delay)
40 delay = 500;
41 var showTime = new Date().getTime() + delay;
42 return function(result) {
43 if (result.conflict || !result.title && !result.content) {
44 deferred.reject();
45 } else {
46 var remainingTimeout = Math.max(0, showTime - new Date().getTime());
47 setTimeout(function() {
48 if (!params.sendFullText && result.stateId !== undefined
49 && result.stateId != editorContext.getServerState().stateId)
50 deferred.reject();
51 else
52 deferred.resolve(result);
53 }, remainingTimeout);
54 }
55 };
56 };
57
58 return HoverService;
59}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/LoadResourceService.js b/language-web/src/main/js/xtext/services/LoadResourceService.js
deleted file mode 100644
index b5a315c3..00000000
--- a/language-web/src/main/js/xtext/services/LoadResourceService.js
+++ /dev/null
@@ -1,42 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for loading resources. The resulting text is passed to the editor context.
14 */
15 function LoadResourceService(serviceUrl, resourceId, revert) {
16 this.initialize(serviceUrl, revert ? 'revert' : 'load', resourceId);
17 };
18
19 LoadResourceService.prototype = new XtextService();
20
21 LoadResourceService.prototype._initServerData = function(serverData, editorContext, params) {
22 return {
23 suppressContent: true,
24 httpMethod: this._serviceType == 'revert' ? 'POST' : 'GET'
25 };
26 };
27
28 LoadResourceService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
29 return function(result) {
30 editorContext.setText(result.fullText);
31 editorContext.clearUndoStack();
32 editorContext.setDirty(result.dirty);
33 var listeners = editorContext.updateServerState(result.fullText, result.stateId);
34 for (var i = 0; i < listeners.length; i++) {
35 listeners[i](params);
36 }
37 deferred.resolve(result);
38 }
39 }
40
41 return LoadResourceService;
42}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/OccurrencesService.js b/language-web/src/main/js/xtext/services/OccurrencesService.js
deleted file mode 100644
index 2e2d0b1a..00000000
--- a/language-web/src/main/js/xtext/services/OccurrencesService.js
+++ /dev/null
@@ -1,39 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for marking occurrences.
14 */
15 function OccurrencesService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'occurrences', resourceId, updateService);
17 };
18
19 OccurrencesService.prototype = new XtextService();
20
21 OccurrencesService.prototype._initServerData = function(serverData, editorContext, params) {
22 if (params.offset)
23 serverData.caretOffset = params.offset;
24 else
25 serverData.caretOffset = editorContext.getCaretOffset();
26 };
27
28 OccurrencesService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
29 return function(result) {
30 if (result.conflict || !params.sendFullText && result.stateId !== undefined
31 && result.stateId != editorContext.getServerState().stateId)
32 deferred.reject();
33 else
34 deferred.resolve(result);
35 }
36 }
37
38 return OccurrencesService;
39}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/SaveResourceService.js b/language-web/src/main/js/xtext/services/SaveResourceService.js
deleted file mode 100644
index 66cdaff5..00000000
--- a/language-web/src/main/js/xtext/services/SaveResourceService.js
+++ /dev/null
@@ -1,32 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for saving resources.
14 */
15 function SaveResourceService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'save', resourceId);
17 };
18
19 SaveResourceService.prototype = new XtextService();
20
21 SaveResourceService.prototype._initServerData = function(serverData, editorContext, params) {
22 return {
23 httpMethod: 'POST'
24 };
25 };
26
27 SaveResourceService.prototype._processResult = function(result, editorContext) {
28 editorContext.setDirty(false);
29 };
30
31 return SaveResourceService;
32}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/UpdateService.js b/language-web/src/main/js/xtext/services/UpdateService.js
deleted file mode 100644
index b78d846d..00000000
--- a/language-web/src/main/js/xtext/services/UpdateService.js
+++ /dev/null
@@ -1,159 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for updating the server-side representation of a resource.
14 * This service only makes sense with a stateful server, where an update request is sent
15 * after each modification. This can greatly improve response times compared to the
16 * stateless alternative, where the full text content is sent with each service request.
17 */
18 function UpdateService(serviceUrl, resourceId) {
19 this.initialize(serviceUrl, 'update', resourceId, this);
20 this._completionCallbacks = [];
21 };
22
23 UpdateService.prototype = new XtextService();
24
25 /**
26 * Compute a delta between two versions of a text. If a difference is found, the result
27 * contains three properties:
28 * deltaText - the text to insert into s1
29 * deltaOffset - the text insertion offset
30 * deltaReplaceLength - the number of characters that shall be replaced by the inserted text
31 */
32 UpdateService.prototype.computeDelta = function(s1, s2, result) {
33 var start = 0, s1length = s1.length, s2length = s2.length;
34 while (start < s1length && start < s2length && s1.charCodeAt(start) === s2.charCodeAt(start)) {
35 start++;
36 }
37 if (start === s1length && start === s2length) {
38 return;
39 }
40 result.deltaOffset = start;
41 if (start === s1length) {
42 result.deltaText = s2.substring(start, s2length);
43 result.deltaReplaceLength = 0;
44 return;
45 } else if (start === s2length) {
46 result.deltaText = '';
47 result.deltaReplaceLength = s1length - start;
48 return;
49 }
50
51 var end1 = s1length - 1, end2 = s2length - 1;
52 while (end1 >= start && end2 >= start && s1.charCodeAt(end1) === s2.charCodeAt(end2)) {
53 end1--;
54 end2--;
55 }
56 result.deltaText = s2.substring(start, end2 + 1);
57 result.deltaReplaceLength = end1 - start + 1;
58 };
59
60 /**
61 * Invoke all completion callbacks and clear the list afterwards.
62 */
63 UpdateService.prototype.onComplete = function(xhr, textStatus) {
64 var callbacks = this._completionCallbacks;
65 this._completionCallbacks = [];
66 for (var i = 0; i < callbacks.length; i++) {
67 var callback = callbacks[i].callback;
68 var params = callbacks[i].params;
69 callback(params);
70 }
71 }
72
73 /**
74 * Add a callback to be invoked when the service call has completed.
75 */
76 UpdateService.prototype.addCompletionCallback = function(callback, params) {
77 this._completionCallbacks.push({callback: callback, params: params});
78 }
79
80 UpdateService.prototype.invoke = function(editorContext, params, deferred) {
81 if (deferred === undefined) {
82 deferred = jQuery.Deferred();
83 }
84 var knownServerState = editorContext.getServerState();
85 if (knownServerState.updateInProgress) {
86 var self = this;
87 this.addCompletionCallback(function() { self.invoke(editorContext, params, deferred) });
88 return deferred.promise();
89 }
90
91 var serverData = {
92 contentType: params.contentType
93 };
94 var currentText = editorContext.getText();
95 if (params.sendFullText || knownServerState.text === undefined) {
96 serverData.fullText = currentText;
97 } else {
98 this.computeDelta(knownServerState.text, currentText, serverData);
99 if (serverData.deltaText === undefined) {
100 if (params.forceUpdate) {
101 serverData.deltaText = '';
102 serverData.deltaOffset = editorContext.getCaretOffset();
103 serverData.deltaReplaceLength = 0;
104 } else {
105 deferred.resolve(knownServerState);
106 this.onComplete();
107 return deferred.promise();
108 }
109 }
110 serverData.requiredStateId = knownServerState.stateId;
111 }
112
113 knownServerState.updateInProgress = true;
114 var self = this;
115 self.sendRequest(editorContext, {
116 type: 'PUT',
117 data: serverData,
118
119 success: function(result) {
120 if (result.conflict) {
121 // The server has lost its session state and the resource is loaded from the server
122 if (knownServerState.text !== undefined) {
123 delete knownServerState.updateInProgress;
124 delete knownServerState.text;
125 delete knownServerState.stateId;
126 self.invoke(editorContext, params, deferred);
127 } else {
128 deferred.reject(result.conflict);
129 }
130 return false;
131 }
132 var listeners = editorContext.updateServerState(currentText, result.stateId);
133 for (var i = 0; i < listeners.length; i++) {
134 self.addCompletionCallback(listeners[i], params);
135 }
136 deferred.resolve(result);
137 },
138
139 error: function(xhr, textStatus, errorThrown) {
140 if (xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) {
141 // The server has lost its session state and the resource is not loaded from the server
142 delete knownServerState.updateInProgress;
143 delete knownServerState.text;
144 delete knownServerState.stateId;
145 self.invoke(editorContext, params, deferred);
146 return true;
147 }
148 deferred.reject(errorThrown);
149 },
150
151 complete: self.onComplete.bind(self)
152 }, true);
153 return deferred.promise().always(function() {
154 knownServerState.updateInProgress = false;
155 });
156 };
157
158 return UpdateService;
159}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/ValidationService.js b/language-web/src/main/js/xtext/services/ValidationService.js
deleted file mode 100644
index 85c9953d..00000000
--- a/language-web/src/main/js/xtext/services/ValidationService.js
+++ /dev/null
@@ -1,33 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for validation.
14 */
15 function ValidationService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'validate', resourceId);
17 };
18
19 ValidationService.prototype = new XtextService();
20
21 ValidationService.prototype._checkPreconditions = function(editorContext, params) {
22 return this._state === undefined;
23 }
24
25 ValidationService.prototype._onConflict = function(editorContext, cause) {
26 this.setState(undefined);
27 return {
28 suppressForcedUpdate: true
29 };
30 };
31
32 return ValidationService;
33}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/XtextService.js b/language-web/src/main/js/xtext/services/XtextService.js
deleted file mode 100644
index d3a4842f..00000000
--- a/language-web/src/main/js/xtext/services/XtextService.js
+++ /dev/null
@@ -1,280 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['jquery'], function(jQuery) {
11
12 var globalState = {};
13
14 /**
15 * Generic service implementation that can serve as superclass for specialized services.
16 */
17 function XtextService() {};
18
19 /**
20 * Initialize the request metadata for this service class. Two variants:
21 * - initialize(serviceUrl, serviceType, resourceId, updateService)
22 * - initialize(xtextServices, serviceType)
23 */
24 XtextService.prototype.initialize = function() {
25 this._serviceType = arguments[1];
26 if (typeof(arguments[0]) === 'string') {
27 this._requestUrl = arguments[0] + '/' + this._serviceType;
28 var resourceId = arguments[2];
29 if (resourceId)
30 this._encodedResourceId = encodeURIComponent(resourceId);
31 this._updateService = arguments[3];
32 } else {
33 var xtextServices = arguments[0];
34 if (xtextServices.options) {
35 this._requestUrl = xtextServices.options.serviceUrl + '/' + this._serviceType;
36 var resourceId = xtextServices.options.resourceId;
37 if (resourceId)
38 this._encodedResourceId = encodeURIComponent(resourceId);
39 }
40 this._updateService = xtextServices.updateService;
41 }
42 }
43
44 XtextService.prototype.setState = function(state) {
45 this._state = state;
46 }
47
48 /**
49 * Invoke the service with default service behavior.
50 */
51 XtextService.prototype.invoke = function(editorContext, params, deferred, callbacks) {
52 if (deferred === undefined) {
53 deferred = jQuery.Deferred();
54 }
55 if (jQuery.isFunction(this._checkPreconditions) && !this._checkPreconditions(editorContext, params)) {
56 deferred.reject();
57 return deferred.promise();
58 }
59 var serverData = {
60 contentType: params.contentType
61 };
62 var initResult;
63 if (jQuery.isFunction(this._initServerData))
64 initResult = this._initServerData(serverData, editorContext, params);
65 var httpMethod = 'GET';
66 if (initResult && initResult.httpMethod)
67 httpMethod = initResult.httpMethod;
68 var self = this;
69 if (!(initResult && initResult.suppressContent)) {
70 if (params.sendFullText) {
71 serverData.fullText = editorContext.getText();
72 httpMethod = 'POST';
73 } else {
74 var knownServerState = editorContext.getServerState();
75 if (knownServerState.updateInProgress) {
76 if (self._updateService) {
77 self._updateService.addCompletionCallback(function() {
78 self.invoke(editorContext, params, deferred);
79 });
80 } else {
81 deferred.reject();
82 }
83 return deferred.promise();
84 }
85 if (knownServerState.stateId !== undefined) {
86 serverData.requiredStateId = knownServerState.stateId;
87 }
88 }
89 }
90
91 var onSuccess;
92 if (jQuery.isFunction(this._getSuccessCallback)) {
93 onSuccess = this._getSuccessCallback(editorContext, params, deferred);
94 } else {
95 onSuccess = function(result) {
96 if (result.conflict) {
97 if (self._increaseRecursionCount(editorContext)) {
98 var onConflictResult;
99 if (jQuery.isFunction(self._onConflict)) {
100 onConflictResult = self._onConflict(editorContext, result.conflict);
101 }
102 if (!(onConflictResult && onConflictResult.suppressForcedUpdate) && !params.sendFullText
103 && result.conflict == 'invalidStateId' && self._updateService) {
104 self._updateService.addCompletionCallback(function() {
105 self.invoke(editorContext, params, deferred);
106 });
107 var knownServerState = editorContext.getServerState();
108 delete knownServerState.stateId;
109 delete knownServerState.text;
110 self._updateService.invoke(editorContext, params);
111 } else {
112 self.invoke(editorContext, params, deferred);
113 }
114 } else {
115 deferred.reject();
116 }
117 return false;
118 }
119 if (jQuery.isFunction(self._processResult)) {
120 var processedResult = self._processResult(result, editorContext);
121 if (processedResult) {
122 deferred.resolve(processedResult);
123 return true;
124 }
125 }
126 deferred.resolve(result);
127 };
128 }
129
130 var onError = function(xhr, textStatus, errorThrown) {
131 if (xhr.status == 404 && !params.loadFromServer && self._increaseRecursionCount(editorContext)) {
132 var onConflictResult;
133 if (jQuery.isFunction(self._onConflict)) {
134 onConflictResult = self._onConflict(editorContext, errorThrown);
135 }
136 var knownServerState = editorContext.getServerState();
137 if (!(onConflictResult && onConflictResult.suppressForcedUpdate)
138 && knownServerState.text !== undefined && self._updateService) {
139 self._updateService.addCompletionCallback(function() {
140 self.invoke(editorContext, params, deferred);
141 });
142 delete knownServerState.stateId;
143 delete knownServerState.text;
144 self._updateService.invoke(editorContext, params);
145 return true;
146 }
147 }
148 deferred.reject(errorThrown);
149 }
150
151 self.sendRequest(editorContext, {
152 type: httpMethod,
153 data: serverData,
154 success: onSuccess,
155 error: onError
156 }, !params.sendFullText);
157 return deferred.promise().always(function() {
158 self._recursionCount = undefined;
159 });
160 }
161
162 /**
163 * Send an HTTP request to invoke the service.
164 */
165 XtextService.prototype.sendRequest = function(editorContext, settings, needsSession) {
166 var self = this;
167 self.setState('started');
168 var corsEnabled = editorContext.xtextServices.options['enableCors'];
169 if(corsEnabled) {
170 settings.crossDomain = true;
171 settings.xhrFields = {withCredentials: true};
172 }
173 var onSuccess = settings.success;
174 settings.success = function(result) {
175 var accepted = true;
176 if (jQuery.isFunction(onSuccess)) {
177 accepted = onSuccess(result);
178 }
179 if (accepted || accepted === undefined) {
180 self.setState('finished');
181 if (editorContext.xtextServices) {
182 var successListeners = editorContext.xtextServices.successListeners;
183 if (successListeners) {
184 for (var i = 0; i < successListeners.length; i++) {
185 var listener = successListeners[i];
186 if (jQuery.isFunction(listener)) {
187 listener(self._serviceType, result);
188 }
189 }
190 }
191 }
192 }
193 };
194
195 var onError = settings.error;
196 settings.error = function(xhr, textStatus, errorThrown) {
197 var resolved = false;
198 if (jQuery.isFunction(onError)) {
199 resolved = onError(xhr, textStatus, errorThrown);
200 }
201 if (!resolved) {
202 self.setState(undefined);
203 self._reportError(editorContext, textStatus, errorThrown, xhr);
204 }
205 };
206
207 settings.async = true;
208 var requestUrl = self._requestUrl;
209 if (!settings.data.resource && self._encodedResourceId) {
210 if (requestUrl.indexOf('?') >= 0)
211 requestUrl += '&resource=' + self._encodedResourceId;
212 else
213 requestUrl += '?resource=' + self._encodedResourceId;
214 }
215
216 if (needsSession && globalState._initPending) {
217 // We have to wait until the initial request has finished to make sure the client has
218 // received a valid session id
219 if (!globalState._waitingRequests)
220 globalState._waitingRequests = [];
221 globalState._waitingRequests.push({requestUrl: requestUrl, settings: settings});
222 } else {
223 if (needsSession && !globalState._initDone) {
224 globalState._initPending = true;
225 var onComplete = settings.complete;
226 settings.complete = function(xhr, textStatus) {
227 if (jQuery.isFunction(onComplete)) {
228 onComplete(xhr, textStatus);
229 }
230 delete globalState._initPending;
231 globalState._initDone = true;
232 if (globalState._waitingRequests) {
233 for (var i = 0; i < globalState._waitingRequests.length; i++) {
234 var request = globalState._waitingRequests[i];
235 jQuery.ajax(request.requestUrl, request.settings);
236 }
237 delete globalState._waitingRequests;
238 }
239 }
240 }
241 jQuery.ajax(requestUrl, settings);
242 }
243 }
244
245 /**
246 * Use this in case of a conflict before retrying the service invocation. If the number
247 * of retries exceeds the limit, an error is reported and the function returns false.
248 */
249 XtextService.prototype._increaseRecursionCount = function(editorContext) {
250 if (this._recursionCount === undefined)
251 this._recursionCount = 1;
252 else
253 this._recursionCount++;
254
255 if (this._recursionCount >= 10) {
256 this._reportError(editorContext, 'warning', 'Xtext service request failed after 10 attempts.', {});
257 return false;
258 }
259 return true;
260 },
261
262 /**
263 * Report an error to the listeners.
264 */
265 XtextService.prototype._reportError = function(editorContext, severity, message, requestData) {
266 if (editorContext.xtextServices) {
267 var errorListeners = editorContext.xtextServices.errorListeners;
268 if (errorListeners) {
269 for (var i = 0; i < errorListeners.length; i++) {
270 var listener = errorListeners[i];
271 if (jQuery.isFunction(listener)) {
272 listener(this._serviceType, severity, message, requestData);
273 }
274 }
275 }
276 }
277 }
278
279 return XtextService;
280});
diff --git a/language-web/src/main/js/xtext/xtext-codemirror.d.ts b/language-web/src/main/js/xtext/xtext-codemirror.d.ts
deleted file mode 100644
index fff850b8..00000000
--- a/language-web/src/main/js/xtext/xtext-codemirror.d.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Editor } from 'codemirror';
2
3export function createEditor(options: IXtextOptions): IXtextCodeMirrorEditor;
4
5export function createServices(editor: Editor, options: IXtextOptions): IXtextServices;
6
7export function removeServices(editor: Editor): void;
8
9export interface IXtextOptions {
10 baseUrl?: string;
11 contentType?: string;
12 dirtyElement?: string | Element;
13 dirtyStatusClass?: string;
14 document?: Document;
15 enableContentAssistService?: boolean;
16 enableCors?: boolean;
17 enableFormattingAction?: boolean;
18 enableFormattingService?: boolean;
19 enableGeneratorService?: boolean;
20 enableHighlightingService?: boolean;
21 enableOccurrencesService?: boolean;
22 enableSaveAction?: boolean;
23 enableValidationService?: boolean;
24 loadFromServer?: boolean;
25 mode?: string;
26 parent?: string | Element;
27 parentClass?: string;
28 resourceId?: string;
29 selectionUpdateDelay?: number;
30 sendFullText?: boolean;
31 serviceUrl?: string;
32 showErrorDialogs?: boolean;
33 syntaxDefinition?: string;
34 textUpdateDelay?: number;
35 xtextLang?: string;
36}
37
38export interface IXtextCodeMirrorEditor extends Editor {
39 xtextServices: IXtextServices;
40}
41
42export interface IXtextServices {
43}
diff --git a/language-web/src/main/js/xtext/xtext-codemirror.js b/language-web/src/main/js/xtext/xtext-codemirror.js
deleted file mode 100644
index d246172a..00000000
--- a/language-web/src/main/js/xtext/xtext-codemirror.js
+++ /dev/null
@@ -1,473 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10/*
11 * Use `createEditor(options)` to create an Xtext editor. You can specify options either
12 * through the function parameter or through `data-editor-x` attributes, where x is an
13 * option name with camelCase converted to hyphen-separated.
14 * In addition to the options supported by CodeMirror (https://codemirror.net/doc/manual.html#config),
15 * the following options are available:
16 *
17 * baseUrl = "/" {String}
18 * The path segment where the Xtext service is found; see serviceUrl option.
19 * contentType {String}
20 * The content type included in requests to the Xtext server.
21 * dirtyElement {String | DOMElement}
22 * An element into which the dirty status class is written when the editor is marked dirty;
23 * it can be either a DOM element or an ID for a DOM element.
24 * dirtyStatusClass = 'dirty' {String}
25 * A CSS class name written into the dirtyElement when the editor is marked dirty.
26 * document {Document}
27 * The document; if not specified, the global document is used.
28 * enableContentAssistService = true {Boolean}
29 * Whether content assist should be enabled.
30 * enableCors = true {Boolean}
31 * Whether CORS should be enabled for service request.
32 * enableFormattingAction = false {Boolean}
33 * Whether the formatting action should be bound to the standard keystroke ctrl+shift+s / cmd+shift+f.
34 * enableFormattingService = true {Boolean}
35 * Whether text formatting should be enabled.
36 * enableGeneratorService = true {Boolean}
37 * Whether code generation should be enabled (must be triggered through JavaScript code).
38 * enableHighlightingService = true {Boolean}
39 * Whether semantic highlighting (computed on the server) should be enabled.
40 * enableOccurrencesService = true {Boolean}
41 * Whether marking occurrences should be enabled.
42 * enableSaveAction = false {Boolean}
43 * Whether the save action should be bound to the standard keystroke ctrl+s / cmd+s.
44 * enableValidationService = true {Boolean}
45 * Whether validation should be enabled.
46 * loadFromServer = true {Boolean}
47 * Whether to load the editor content from the server.
48 * mode {String}
49 * The name of the syntax highlighting mode to use; the mode has to be registered externally
50 * (see CodeMirror documentation).
51 * parent = 'xtext-editor' {String | DOMElement}
52 * The parent element for the view; it can be either a DOM element or an ID for a DOM element.
53 * parentClass = 'xtext-editor' {String}
54 * If the 'parent' option is not given, this option is used to find elements that match the given class name.
55 * resourceId {String}
56 * The identifier of the resource displayed in the text editor; this option is sent to the server to
57 * communicate required information on the respective resource.
58 * selectionUpdateDelay = 550 {Number}
59 * The number of milliseconds to wait after a selection change before Xtext services are invoked.
60 * sendFullText = false {Boolean}
61 * Whether the full text shall be sent to the server with each request; use this if you want
62 * the server to run in stateless mode. If the option is inactive, the server state is updated regularly.
63 * serviceUrl {String}
64 * The URL of the Xtext servlet; if no value is given, it is constructed using the baseUrl option in the form
65 * {location.protocol}//{location.host}{baseUrl}xtext-service
66 * showErrorDialogs = false {Boolean}
67 * Whether errors should be displayed in popup dialogs.
68 * syntaxDefinition {String}
69 * If the 'mode' option is not set, the default mode 'xtext/{xtextLang}' is used. Set this option to
70 * 'none' to suppress this behavior and disable syntax highlighting.
71 * textUpdateDelay = 500 {Number}
72 * The number of milliseconds to wait after a text change before Xtext services are invoked.
73 * xtextLang {String}
74 * The language name (usually the file extension configured for the language).
75 */
76define([
77 'jquery',
78 'codemirror',
79 'codemirror/addon/hint/show-hint',
80 'xtext/compatibility',
81 'xtext/ServiceBuilder',
82 'xtext/CodeMirrorEditorContext',
83 'codemirror/mode/javascript/javascript'
84], function(jQuery, CodeMirror, ShowHint, compatibility, ServiceBuilder, EditorContext) {
85
86 var exports = {};
87
88 /**
89 * Create one or more Xtext editor instances configured with the given options.
90 * The return value is either a CodeMirror editor or an array of CodeMirror editors.
91 */
92 exports.createEditor = function(options) {
93 if (!options)
94 options = {};
95
96 var query;
97 if (jQuery.type(options.parent) === 'string') {
98 query = jQuery('#' + options.parent, options.document);
99 } else if (options.parent) {
100 query = jQuery(options.parent);
101 } else if (jQuery.type(options.parentClass) === 'string') {
102 query = jQuery('.' + options.parentClass, options.document);
103 } else {
104 query = jQuery('#xtext-editor', options.document);
105 if (query.length == 0)
106 query = jQuery('.xtext-editor', options.document);
107 }
108
109 var editors = [];
110 query.each(function(index, parent) {
111 var editorOptions = ServiceBuilder.mergeParentOptions(parent, options);
112 if (!editorOptions.value)
113 editorOptions.value = jQuery(parent).text();
114 var editor = CodeMirror(function(element) {
115 jQuery(parent).empty().append(element);
116 }, editorOptions);
117
118 exports.createServices(editor, editorOptions);
119 editors[index] = editor;
120 });
121
122 if (editors.length == 1)
123 return editors[0];
124 else
125 return editors;
126 }
127
128 function CodeMirrorServiceBuilder(editor, xtextServices) {
129 this.editor = editor;
130 xtextServices.editorContext._highlightingMarkers = [];
131 xtextServices.editorContext._validationMarkers = [];
132 xtextServices.editorContext._occurrenceMarkers = [];
133 ServiceBuilder.call(this, xtextServices);
134 }
135 CodeMirrorServiceBuilder.prototype = new ServiceBuilder();
136
137 /**
138 * Configure Xtext services for the given editor. The editor does not have to be created
139 * with createEditor(options).
140 */
141 exports.createServices = function(editor, options) {
142 if (options.enableValidationService || options.enableValidationService === undefined) {
143 editor.setOption('gutters', ['annotations-gutter']);
144 }
145 var xtextServices = {
146 options: options,
147 editorContext: new EditorContext(editor)
148 };
149 var serviceBuilder = new CodeMirrorServiceBuilder(editor, xtextServices);
150 serviceBuilder.createServices();
151 xtextServices.serviceBuilder = serviceBuilder;
152 editor.xtextServices = xtextServices;
153 return xtextServices;
154 }
155
156 /**
157 * Remove all services and listeners that have been previously created with createServices(editor, options).
158 */
159 exports.removeServices = function(editor) {
160 if (!editor.xtextServices)
161 return;
162 var services = editor.xtextServices;
163 if (services.modelChangeListener)
164 editor.off('changes', services.modelChangeListener);
165 if (services.cursorActivityListener)
166 editor.off('cursorActivity', services.cursorActivityListener);
167 if (services.saveKeyMap)
168 editor.removeKeyMap(services.saveKeyMap);
169 if (services.contentAssistKeyMap)
170 editor.removeKeyMap(services.contentAssistKeyMap);
171 if (services.formatKeyMap)
172 editor.removeKeyMap(services.formatKeyMap);
173 var editorContext = services.editorContext;
174 var highlightingMarkers = editorContext._highlightingMarkers;
175 if (highlightingMarkers) {
176 for (var i = 0; i < highlightingMarkers.length; i++) {
177 highlightingMarkers[i].clear();
178 }
179 }
180 if (editorContext._validationAnnotations)
181 services.serviceBuilder._clearAnnotations(editorContext._validationAnnotations);
182 var validationMarkers = editorContext._validationMarkers;
183 if (validationMarkers) {
184 for (var i = 0; i < validationMarkers.length; i++) {
185 validationMarkers[i].clear();
186 }
187 }
188 var occurrenceMarkers = editorContext._occurrenceMarkers;
189 if (occurrenceMarkers) {
190 for (var i = 0; i < occurrenceMarkers.length; i++)  {
191 occurrenceMarkers[i].clear();
192 }
193 }
194 delete editor.xtextServices;
195 }
196
197 /**
198 * Syntax highlighting (without semantic highlighting).
199 */
200 CodeMirrorServiceBuilder.prototype.setupSyntaxHighlighting = function() {
201 var options = this.services.options;
202 // If the mode option is set, syntax highlighting has already been configured by CM
203 if (!options.mode && options.syntaxDefinition != 'none' && options.xtextLang) {
204 this.editor.setOption('mode', 'xtext/' + options.xtextLang);
205 }
206 }
207
208 /**
209 * Document update service.
210 */
211 CodeMirrorServiceBuilder.prototype.setupUpdateService = function(refreshDocument) {
212 var services = this.services;
213 var editorContext = services.editorContext;
214 var textUpdateDelay = services.options.textUpdateDelay;
215 if (!textUpdateDelay)
216 textUpdateDelay = 500;
217 services.modelChangeListener = function(event) {
218 if (!event._xtext_init)
219 editorContext.setDirty(true);
220 if (editorContext._modelChangeTimeout)
221 clearTimeout(editorContext._modelChangeTimeout);
222 editorContext._modelChangeTimeout = setTimeout(function() {
223 if (services.options.sendFullText)
224 refreshDocument();
225 else
226 services.update();
227 }, textUpdateDelay);
228 }
229 if (!services.options.resourceId || !services.options.loadFromServer)
230 services.modelChangeListener({_xtext_init: true});
231 this.editor.on('changes', services.modelChangeListener);
232 }
233
234 /**
235 * Persistence services: load, save, and revert.
236 */
237 CodeMirrorServiceBuilder.prototype.setupPersistenceServices = function() {
238 var services = this.services;
239 if (services.options.enableSaveAction) {
240 var userAgent = navigator.userAgent.toLowerCase();
241 var saveFunction = function(editor) {
242 services.saveResource();
243 };
244 services.saveKeyMap = /mac os/.test(userAgent) ? {'Cmd-S': saveFunction}: {'Ctrl-S': saveFunction};
245 this.editor.addKeyMap(services.saveKeyMap);
246 }
247 }
248
249 /**
250 * Content assist service.
251 */
252 CodeMirrorServiceBuilder.prototype.setupContentAssistService = function() {
253 var services = this.services;
254 var editorContext = services.editorContext;
255 services.contentAssistKeyMap = {'Ctrl-Space': function(editor) {
256 var params = ServiceBuilder.copy(services.options);
257 var cursor = editor.getCursor();
258 params.offset = editor.indexFromPos(cursor);
259 services.contentAssistService.invoke(editorContext, params).done(function(entries) {
260 editor.showHint({hint: function(editor, options) {
261 return {
262 list: entries.map(function(entry) {
263 var displayText;
264 if (entry.label)
265 displayText = entry.label;
266 else
267 displayText = entry.proposal;
268 if (entry.description)
269 displayText += ' (' + entry.description + ')';
270 var prefixLength = 0
271 if (entry.prefix)
272 prefixLength = entry.prefix.length
273 return {
274 text: entry.proposal,
275 displayText: displayText,
276 from: {
277 line: cursor.line,
278 ch: cursor.ch - prefixLength
279 }
280 };
281 }),
282 from: cursor,
283 to: cursor
284 };
285 }});
286 });
287 }};
288 this.editor.addKeyMap(services.contentAssistKeyMap);
289 }
290
291 /**
292 * Semantic highlighting service.
293 */
294 CodeMirrorServiceBuilder.prototype.doHighlighting = function() {
295 var services = this.services;
296 var editorContext = services.editorContext;
297 var editor = this.editor;
298 services.computeHighlighting().always(function() {
299 var highlightingMarkers = editorContext._highlightingMarkers;
300 if (highlightingMarkers) {
301 for (var i = 0; i < highlightingMarkers.length; i++) {
302 highlightingMarkers[i].clear();
303 }
304 }
305 editorContext._highlightingMarkers = [];
306 }).done(function(result) {
307 for (var i = 0; i < result.regions.length; ++i) {
308 var region = result.regions[i];
309 var from = editor.posFromIndex(region.offset);
310 var to = editor.posFromIndex(region.offset + region.length);
311 region.styleClasses.forEach(function(styleClass) {
312 var marker = editor.markText(from, to, {className: styleClass});
313 editorContext._highlightingMarkers.push(marker);
314 });
315 }
316 });
317 }
318
319 var annotationWeight = {
320 error: 30,
321 warning: 20,
322 info: 10
323 };
324 CodeMirrorServiceBuilder.prototype._getAnnotationWeight = function(annotation) {
325 if (annotationWeight[annotation] !== undefined)
326 return annotationWeight[annotation];
327 else
328 return 0;
329 }
330
331 CodeMirrorServiceBuilder.prototype._clearAnnotations = function(annotations) {
332 var editor = this.editor;
333 editor.clearGutter('annotations-gutter');
334 for (var i = 0; i < annotations.length; i++) {
335 var annotation = annotations[i];
336 if (annotation) {
337 annotations[i] = undefined;
338 }
339 }
340 }
341
342 CodeMirrorServiceBuilder.prototype._refreshAnnotations = function(annotations) {
343 var editor = this.editor;
344 for (var i = 0; i < annotations.length; i++) {
345 var annotation = annotations[i];
346 if (annotation) {
347 var classProp = ' class="xtext-annotation_' + annotation.type + '"';
348 var titleProp = annotation.description ? ' title="' + annotation.description.replace(/"/g, '&quot;') + '"' : '';
349 var element = jQuery('<div' + classProp + titleProp + '></div>').get(0);
350 editor.setGutterMarker(i, 'annotations-gutter', element);
351 }
352 }
353 }
354
355 /**
356 * Validation service.
357 */
358 CodeMirrorServiceBuilder.prototype.doValidation = function() {
359 var services = this.services;
360 var editorContext = services.editorContext;
361 var editor = this.editor;
362 var self = this;
363 services.validate().always(function() {
364 if (editorContext._validationAnnotations)
365 self._clearAnnotations(editorContext._validationAnnotations);
366 else
367 editorContext._validationAnnotations = [];
368 var validationMarkers = editorContext._validationMarkers;
369 if (validationMarkers) {
370 for (var i = 0; i < validationMarkers.length; i++) {
371 validationMarkers[i].clear();
372 }
373 }
374 editorContext._validationMarkers = [];
375 }).done(function(result) {
376 var validationAnnotations = editorContext._validationAnnotations;
377 for (var i = 0; i < result.issues.length; i++) {
378 var entry = result.issues[i];
379 var annotation = validationAnnotations[entry.line - 1];
380 var weight = self._getAnnotationWeight(entry.severity);
381 if (annotation) {
382 if (annotation.weight < weight) {
383 annotation.type = entry.severity;
384 annotation.weight = weight;
385 }
386 if (annotation.description)
387 annotation.description += '\n' + entry.description;
388 else
389 annotation.description = entry.description;
390 } else {
391 validationAnnotations[entry.line - 1] = {
392 type: entry.severity,
393 weight: weight,
394 description: entry.description
395 };
396 }
397 var from = editor.posFromIndex(entry.offset);
398 var to = editor.posFromIndex(entry.offset + entry.length);
399 var marker = editor.markText(from, to, {
400 className: 'xtext-marker_' + entry.severity,
401 title: entry.description
402 });
403 editorContext._validationMarkers.push(marker);
404 }
405 self._refreshAnnotations(validationAnnotations);
406 });
407 }
408
409 /**
410 * Occurrences service.
411 */
412 CodeMirrorServiceBuilder.prototype.setupOccurrencesService = function() {
413 var services = this.services;
414 var editorContext = services.editorContext;
415 var selectionUpdateDelay = services.options.selectionUpdateDelay;
416 if (!selectionUpdateDelay)
417 selectionUpdateDelay = 550;
418 var editor = this.editor;
419 var self = this;
420 services.cursorActivityListener = function() {
421 if (editorContext._selectionChangeTimeout) {
422 clearTimeout(editorContext._selectionChangeTimeout);
423 }
424 editorContext._selectionChangeTimeout = setTimeout(function() {
425 var params = ServiceBuilder.copy(services.options);
426 var cursor = editor.getCursor();
427 params.offset = editor.indexFromPos(cursor);
428 services.occurrencesService.invoke(editorContext, params).always(function() {
429 var occurrenceMarkers = editorContext._occurrenceMarkers;
430 if (occurrenceMarkers) {
431 for (var i = 0; i < occurrenceMarkers.length; i++)  {
432 occurrenceMarkers[i].clear();
433 }
434 }
435 editorContext._occurrenceMarkers = [];
436 }).done(function(occurrencesResult) {
437 for (var i = 0; i < occurrencesResult.readRegions.length; i++) {
438 var region = occurrencesResult.readRegions[i];
439 var from = editor.posFromIndex(region.offset);
440 var to = editor.posFromIndex(region.offset + region.length);
441 var marker = editor.markText(from, to, {className: 'xtext-marker_read'});
442 editorContext._occurrenceMarkers.push(marker);
443 }
444 for (var i = 0; i < occurrencesResult.writeRegions.length; i++) {
445 var region = occurrencesResult.writeRegions[i];
446 var from = editor.posFromIndex(region.offset);
447 var to = editor.posFromIndex(region.offset + region.length);
448 var marker = editor.markText(from, to, {className: 'xtext-marker_write'});
449 editorContext._occurrenceMarkers.push(marker);
450 }
451 });
452 }, selectionUpdateDelay);
453 }
454 editor.on('cursorActivity', services.cursorActivityListener);
455 }
456
457 /**
458 * Formatting service.
459 */
460 CodeMirrorServiceBuilder.prototype.setupFormattingService = function() {
461 var services = this.services;
462 if (services.options.enableFormattingAction) {
463 var userAgent = navigator.userAgent.toLowerCase();
464 var formatFunction = function(editor) {
465 services.format();
466 };
467 services.formatKeyMap = /mac os/.test(userAgent) ? {'Shift-Cmd-F': formatFunction}: {'Shift-Ctrl-S': formatFunction};
468 this.editor.addKeyMap(services.formatKeyMap);
469 }
470 }
471
472 return exports;
473});
diff --git a/language-web/src/main/js/xtext/xtextMessages.ts b/language-web/src/main/js/xtext/xtextMessages.ts
new file mode 100644
index 00000000..68737958
--- /dev/null
+++ b/language-web/src/main/js/xtext/xtextMessages.ts
@@ -0,0 +1,62 @@
1export interface IXtextWebRequest {
2 id: string;
3
4 request: unknown;
5}
6
7export interface IXtextWebOkResponse {
8 id: string;
9
10 response: unknown;
11}
12
13export function isOkResponse(response: unknown): response is IXtextWebOkResponse {
14 const okResponse = response as IXtextWebOkResponse;
15 return typeof okResponse === 'object'
16 && typeof okResponse.id === 'string'
17 && typeof okResponse.response !== 'undefined';
18}
19
20export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const;
21
22export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number];
23
24export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind {
25 return typeof value === 'string'
26 && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind);
27}
28
29export interface IXtextWebErrorResponse {
30 id: string;
31
32 error: XtextWebErrorKind;
33
34 message: string;
35}
36
37export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse {
38 const errorResponse = response as IXtextWebErrorResponse;
39 return typeof errorResponse === 'object'
40 && typeof errorResponse.id === 'string'
41 && isXtextWebErrorKind(errorResponse.error)
42 && typeof errorResponse.message === 'string';
43}
44
45export interface IXtextWebPushMessage {
46 resource: string;
47
48 stateId: string;
49
50 service: string;
51
52 push: unknown;
53}
54
55export function isPushMessage(response: unknown): response is IXtextWebPushMessage {
56 const pushMessage = response as IXtextWebPushMessage;
57 return typeof pushMessage === 'object'
58 && typeof pushMessage.resource === 'string'
59 && typeof pushMessage.stateId === 'string'
60 && typeof pushMessage.service === 'string'
61 && typeof pushMessage.push !== 'undefined';
62}
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts
new file mode 100644
index 00000000..b2de1e4a
--- /dev/null
+++ b/language-web/src/main/js/xtext/xtextServiceResults.ts
@@ -0,0 +1,239 @@
1export interface IPongResult {
2 pong: string;
3}
4
5export function isPongResult(result: unknown): result is IPongResult {
6 const pongResult = result as IPongResult;
7 return typeof pongResult === 'object'
8 && typeof pongResult.pong === 'string';
9}
10
11export interface IDocumentStateResult {
12 stateId: string;
13}
14
15export function isDocumentStateResult(result: unknown): result is IDocumentStateResult {
16 const documentStateResult = result as IDocumentStateResult;
17 return typeof documentStateResult === 'object'
18 && typeof documentStateResult.stateId === 'string';
19}
20
21export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const;
22
23export type Conflict = typeof VALID_CONFLICTS[number];
24
25export function isConflict(value: unknown): value is Conflict {
26 return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict);
27}
28
29export interface IServiceConflictResult {
30 conflict: Conflict;
31}
32
33export function isServiceConflictResult(result: unknown): result is IServiceConflictResult {
34 const serviceConflictResult = result as IServiceConflictResult;
35 return typeof serviceConflictResult === 'object'
36 && isConflict(serviceConflictResult.conflict);
37}
38
39export function isInvalidStateIdConflictResult(result: unknown): boolean {
40 return isServiceConflictResult(result) && result.conflict === 'invalidStateId';
41}
42
43export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const;
44
45export type Severity = typeof VALID_SEVERITIES[number];
46
47export function isSeverity(value: unknown): value is Severity {
48 return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity);
49}
50
51export interface IIssue {
52 description: string;
53
54 severity: Severity;
55
56 line: number;
57
58 column: number;
59
60 offset: number;
61
62 length: number;
63}
64
65export function isIssue(value: unknown): value is IIssue {
66 const issue = value as IIssue;
67 return typeof issue === 'object'
68 && typeof issue.description === 'string'
69 && isSeverity(issue.severity)
70 && typeof issue.line === 'number'
71 && typeof issue.column === 'number'
72 && typeof issue.offset === 'number'
73 && typeof issue.length === 'number';
74}
75
76export interface IValidationResult {
77 issues: IIssue[];
78}
79
80function isArrayOfType<T>(value: unknown, check: (entry: unknown) => entry is T): value is T[] {
81 return Array.isArray(value) && (value as T[]).every(check);
82}
83
84export function isValidationResult(result: unknown): result is IValidationResult {
85 const validationResult = result as IValidationResult;
86 return typeof validationResult === 'object'
87 && isArrayOfType(validationResult.issues, isIssue);
88}
89
90export interface IReplaceRegion {
91 offset: number;
92
93 length: number;
94
95 text: string;
96}
97
98export function isReplaceRegion(value: unknown): value is IReplaceRegion {
99 const replaceRegion = value as IReplaceRegion;
100 return typeof replaceRegion === 'object'
101 && typeof replaceRegion.offset === 'number'
102 && typeof replaceRegion.length === 'number'
103 && typeof replaceRegion.text === 'string';
104}
105
106export interface ITextRegion {
107 offset: number;
108
109 length: number;
110}
111
112export function isTextRegion(value: unknown): value is ITextRegion {
113 const textRegion = value as ITextRegion;
114 return typeof textRegion === 'object'
115 && typeof textRegion.offset === 'number'
116 && typeof textRegion.length === 'number';
117}
118
119export const VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS = [
120 'TEXT',
121 'METHOD',
122 'FUNCTION',
123 'CONSTRUCTOR',
124 'FIELD',
125 'VARIABLE',
126 'CLASS',
127 'INTERFACE',
128 'MODULE',
129 'PROPERTY',
130 'UNIT',
131 'VALUE',
132 'ENUM',
133 'KEYWORD',
134 'SNIPPET',
135 'COLOR',
136 'FILE',
137 'REFERENCE',
138 'UNKNOWN',
139] as const;
140
141export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number];
142
143export function isXtextContentAssistEntryKind(
144 value: unknown,
145): value is XtextContentAssistEntryKind {
146 return typeof value === 'string'
147 && VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS.includes(value as XtextContentAssistEntryKind);
148}
149
150export interface IContentAssistEntry {
151 prefix: string;
152
153 proposal: string;
154
155 label?: string;
156
157 description?: string;
158
159 documentation?: string;
160
161 escapePosition?: number;
162
163 textReplacements: IReplaceRegion[];
164
165 editPositions: ITextRegion[];
166
167 kind: XtextContentAssistEntryKind | string;
168}
169
170function isStringOrUndefined(value: unknown): value is string | undefined {
171 return typeof value === 'string' || typeof value === 'undefined';
172}
173
174function isNumberOrUndefined(value: unknown): value is number | undefined {
175 return typeof value === 'number' || typeof value === 'undefined';
176}
177
178export function isContentAssistEntry(value: unknown): value is IContentAssistEntry {
179 const entry = value as IContentAssistEntry;
180 return typeof entry === 'object'
181 && typeof entry.prefix === 'string'
182 && typeof entry.proposal === 'string'
183 && isStringOrUndefined(entry.label)
184 && isStringOrUndefined(entry.description)
185 && isStringOrUndefined(entry.documentation)
186 && isNumberOrUndefined(entry.escapePosition)
187 && isArrayOfType(entry.textReplacements, isReplaceRegion)
188 && isArrayOfType(entry.editPositions, isTextRegion)
189 && typeof entry.kind === 'string';
190}
191
192export interface IContentAssistResult extends IDocumentStateResult {
193 entries: IContentAssistEntry[];
194}
195
196export function isContentAssistResult(result: unknown): result is IContentAssistResult {
197 const contentAssistResult = result as IContentAssistResult;
198 return isDocumentStateResult(result)
199 && isArrayOfType(contentAssistResult.entries, isContentAssistEntry);
200}
201
202export interface IHighlightingRegion {
203 offset: number;
204
205 length: number;
206
207 styleClasses: string[];
208}
209
210export function isHighlightingRegion(value: unknown): value is IHighlightingRegion {
211 const region = value as IHighlightingRegion;
212 return typeof region === 'object'
213 && typeof region.offset === 'number'
214 && typeof region.length === 'number'
215 && isArrayOfType(region.styleClasses, (s): s is string => typeof s === 'string');
216}
217
218export interface IHighlightingResult {
219 regions: IHighlightingRegion[];
220}
221
222export function isHighlightingResult(result: unknown): result is IHighlightingResult {
223 const highlightingResult = result as IHighlightingResult;
224 return typeof highlightingResult === 'object'
225 && isArrayOfType(highlightingResult.regions, isHighlightingRegion);
226}
227
228export interface IOccurrencesResult extends IDocumentStateResult {
229 writeRegions: ITextRegion[];
230
231 readRegions: ITextRegion[];
232}
233
234export function isOccurrencesResult(result: unknown): result is IOccurrencesResult {
235 const occurrencesResult = result as IOccurrencesResult;
236 return isDocumentStateResult(occurrencesResult)
237 && isArrayOfType(occurrencesResult.writeRegions, isTextRegion)
238 && isArrayOfType(occurrencesResult.readRegions, isTextRegion);
239}