diff options
author | 2021-10-31 19:41:24 +0100 | |
---|---|---|
committer | 2021-10-31 19:41:24 +0100 | |
commit | 7918d78948de7f349f9948837caf76f2a514c96c (patch) | |
tree | 0f7c24a9e0046dffd719d6a66be4a1f73b7fa4c7 /language-web/src/main | |
parent | Merge pull request #7 from golej-marci/language-to-store (diff) | |
parent | chore: bump dependency versions (diff) | |
download | refinery-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')
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 | |||
25 | body { | ||
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 | |||
156 | li.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 @@ | |||
1 | package tools.refinery.language.web; | 1 | package tools.refinery.language.web; |
2 | 2 | ||
3 | import java.io.IOException; | 3 | import java.io.IOException; |
4 | import java.time.Duration; | ||
4 | import java.util.regex.Pattern; | 5 | import java.util.regex.Pattern; |
5 | 6 | ||
7 | import org.eclipse.jetty.http.HttpHeader; | ||
8 | |||
6 | import jakarta.servlet.Filter; | 9 | import jakarta.servlet.Filter; |
7 | import jakarta.servlet.FilterChain; | 10 | import jakarta.servlet.FilterChain; |
8 | import jakarta.servlet.FilterConfig; | 11 | import jakarta.servlet.FilterConfig; |
@@ -13,16 +16,11 @@ import jakarta.servlet.http.HttpServletRequest; | |||
13 | import jakarta.servlet.http.HttpServletResponse; | 16 | import jakarta.servlet.http.HttpServletResponse; |
14 | 17 | ||
15 | public class CacheControlFilter implements Filter { | 18 | public 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 | */ |
4 | package tools.refinery.language.web; | 4 | package tools.refinery.language.web; |
5 | 5 | ||
6 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
7 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
8 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
9 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
10 | |||
11 | import tools.refinery.language.web.occurrences.ProblemOccurrencesService; | ||
12 | import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher; | ||
13 | import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess; | ||
14 | import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider; | ||
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 | */ |
10 | public class ProblemWebModule extends AbstractProblemWebModule { | 19 | public class ProblemWebModule extends AbstractProblemWebModule { |
20 | public Class<? extends IWebDocumentProvider> bindIWebDocumentProvider() { | ||
21 | return PushWebDocumentProvider.class; | ||
22 | } | ||
23 | |||
24 | public Class<? extends XtextWebDocumentAccess> bindXtextWebDocumentAccess() { | ||
25 | return PushWebDocumentAccess.class; | ||
26 | } | ||
27 | |||
28 | public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() { | ||
29 | return PushServiceDispatcher.class; | ||
30 | } | ||
31 | |||
32 | public Class<? extends OccurrencesService> bindOccurrencesService() { | ||
33 | return ProblemOccurrencesService.class; | ||
34 | } | ||
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 | */ | ||
4 | package tools.refinery.language.web; | 1 | package tools.refinery.language.web; |
5 | 2 | ||
6 | import org.eclipse.xtext.util.DisposableRegistry; | 3 | import org.eclipse.xtext.util.DisposableRegistry; |
7 | 4 | ||
8 | import jakarta.servlet.ServletException; | 5 | import jakarta.servlet.ServletException; |
9 | import tools.refinery.language.web.xtext.XtextServlet; | 6 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; |
10 | 7 | ||
11 | /** | 8 | public class ProblemWebSocketServlet extends XtextWebSocketServlet { |
12 | * Deploy this class into a servlet container to enable DSL-specific services. | ||
13 | */ | ||
14 | public 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; | |||
9 | import java.net.URI; | 9 | import java.net.URI; |
10 | import java.net.URISyntaxException; | 10 | import java.net.URISyntaxException; |
11 | import java.util.EnumSet; | 11 | import java.util.EnumSet; |
12 | import java.util.Optional; | ||
12 | import java.util.Set; | 13 | import java.util.Set; |
13 | 14 | ||
14 | import org.eclipse.jetty.server.Server; | 15 | import org.eclipse.jetty.server.Server; |
@@ -17,29 +18,36 @@ import org.eclipse.jetty.servlet.DefaultServlet; | |||
17 | import org.eclipse.jetty.servlet.ServletContextHandler; | 18 | import org.eclipse.jetty.servlet.ServletContextHandler; |
18 | import org.eclipse.jetty.servlet.ServletHolder; | 19 | import org.eclipse.jetty.servlet.ServletHolder; |
19 | import org.eclipse.jetty.util.resource.Resource; | 20 | import org.eclipse.jetty.util.resource.Resource; |
21 | import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; | ||
20 | import org.slf4j.Logger; | 22 | import org.slf4j.Logger; |
21 | import org.slf4j.LoggerFactory; | 23 | import org.slf4j.LoggerFactory; |
22 | 24 | ||
23 | import jakarta.servlet.DispatcherType; | 25 | import jakarta.servlet.DispatcherType; |
24 | import jakarta.servlet.SessionTrackingMode; | 26 | import jakarta.servlet.SessionTrackingMode; |
27 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; | ||
25 | 28 | ||
26 | public class ServerLauncher { | 29 | public 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 @@ | |||
1 | package tools.refinery.language.web.occurrences; | ||
2 | |||
3 | import org.eclipse.emf.ecore.EObject; | ||
4 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
5 | |||
6 | import com.google.inject.Singleton; | ||
7 | |||
8 | import tools.refinery.language.model.problem.NamedElement; | ||
9 | |||
10 | @Singleton | ||
11 | public class ProblemOccurrencesService extends OccurrencesService { | ||
12 | @Override | ||
13 | protected boolean filter(EObject element) { | ||
14 | return super.filter(element) && element instanceof NamedElement; | ||
15 | } | ||
16 | } | ||
diff --git a/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 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import java.io.IOException; | ||
12 | import java.net.URLDecoder; | ||
13 | import java.nio.charset.Charset; | ||
14 | import java.util.Collections; | ||
15 | import java.util.Enumeration; | ||
16 | import java.util.HashMap; | ||
17 | import java.util.Map; | ||
18 | import java.util.Set; | ||
19 | |||
20 | import org.eclipse.xtext.web.server.IServiceContext; | ||
21 | import org.eclipse.xtext.web.server.ISession; | ||
22 | |||
23 | import com.google.common.io.CharStreams; | ||
24 | |||
25 | import jakarta.servlet.http.HttpServletRequest; | ||
26 | |||
27 | /** | ||
28 | * Provides the parameters and metadata of an {@link HttpServletRequest}. | ||
29 | */ | ||
30 | class 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 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import org.eclipse.xtext.web.server.ISession; | ||
12 | import org.eclipse.xtext.xbase.lib.Functions.Function0; | ||
13 | |||
14 | import jakarta.servlet.http.HttpSession; | ||
15 | |||
16 | /** | ||
17 | * Provides access to the information stored in a {@link HttpSession}. | ||
18 | */ | ||
19 | record 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 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import java.io.IOException; | ||
12 | import java.util.Set; | ||
13 | |||
14 | import org.eclipse.emf.common.util.URI; | ||
15 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
16 | import org.eclipse.xtext.web.server.IServiceContext; | ||
17 | import org.eclipse.xtext.web.server.IServiceResult; | ||
18 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
19 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
20 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
21 | import org.slf4j.Logger; | ||
22 | import org.slf4j.LoggerFactory; | ||
23 | |||
24 | import com.google.common.base.Objects; | ||
25 | import com.google.common.base.Strings; | ||
26 | import com.google.gson.Gson; | ||
27 | import com.google.inject.Injector; | ||
28 | |||
29 | import jakarta.servlet.ServletException; | ||
30 | import jakarta.servlet.http.HttpServlet; | ||
31 | import jakarta.servlet.http.HttpServletRequest; | ||
32 | import 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 | * @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 | */ | ||
52 | public 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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | |||
7 | public class PongResult implements IServiceResult { | ||
8 | private String pong; | ||
9 | |||
10 | public PongResult(String pong) { | ||
11 | super(); | ||
12 | this.pong = pong; | ||
13 | } | ||
14 | |||
15 | public String getPong() { | ||
16 | return pong; | ||
17 | } | ||
18 | |||
19 | public void setPong(String pong) { | ||
20 | this.pong = pong; | ||
21 | } | ||
22 | |||
23 | @Override | ||
24 | public int hashCode() { | ||
25 | return Objects.hash(pong); | ||
26 | } | ||
27 | |||
28 | @Override | ||
29 | public boolean equals(Object obj) { | ||
30 | if (this == obj) | ||
31 | return true; | ||
32 | if (obj == null) | ||
33 | return false; | ||
34 | if (getClass() != obj.getClass()) | ||
35 | return false; | ||
36 | PongResult other = (PongResult) obj; | ||
37 | return Objects.equals(pong, other.pong); | ||
38 | } | ||
39 | |||
40 | @Override | ||
41 | public String toString() { | ||
42 | return "PongResult [pong=" + pong + "]"; | ||
43 | } | ||
44 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
4 | |||
5 | @FunctionalInterface | ||
6 | public interface ResponseHandler { | ||
7 | void onResponse(XtextWebResponse response) throws ResponseHandlerException; | ||
8 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | public class ResponseHandlerException extends Exception { | ||
4 | |||
5 | private static final long serialVersionUID = 3589866922420268164L; | ||
6 | |||
7 | public ResponseHandlerException(String message, Throwable cause) { | ||
8 | super(message, cause); | ||
9 | } | ||
10 | |||
11 | public ResponseHandlerException(String message) { | ||
12 | super(message); | ||
13 | } | ||
14 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Set; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceContext; | ||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | |||
8 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
9 | |||
10 | public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber) | ||
11 | implements IServiceContext { | ||
12 | @Override | ||
13 | public Set<String> getParameterKeys() { | ||
14 | return delegate.getParameterKeys(); | ||
15 | } | ||
16 | |||
17 | @Override | ||
18 | public String getParameter(String key) { | ||
19 | return delegate.getParameter(key); | ||
20 | } | ||
21 | |||
22 | @Override | ||
23 | public ISession getSession() { | ||
24 | return delegate.getSession(); | ||
25 | } | ||
26 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.lang.ref.WeakReference; | ||
4 | import java.util.ArrayList; | ||
5 | import java.util.HashMap; | ||
6 | import java.util.List; | ||
7 | import java.util.Map; | ||
8 | |||
9 | import org.eclipse.emf.common.util.URI; | ||
10 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
11 | import org.eclipse.xtext.util.IDisposable; | ||
12 | import org.eclipse.xtext.web.server.IServiceContext; | ||
13 | import org.eclipse.xtext.web.server.IServiceResult; | ||
14 | import org.eclipse.xtext.web.server.ISession; | ||
15 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
16 | import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; | ||
17 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
18 | import org.slf4j.Logger; | ||
19 | import org.slf4j.LoggerFactory; | ||
20 | |||
21 | import com.google.common.base.Strings; | ||
22 | import com.google.inject.Injector; | ||
23 | |||
24 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind; | ||
25 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage; | ||
28 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
29 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
30 | import tools.refinery.language.web.xtext.server.push.PushWebDocument; | ||
31 | import tools.refinery.language.web.xtext.servlet.SimpleServiceContext; | ||
32 | |||
33 | public class TransactionExecutor implements IDisposable, PrecomputationListener { | ||
34 | private static final Logger LOG = LoggerFactory.getLogger(TransactionExecutor.class); | ||
35 | |||
36 | private final ISession session; | ||
37 | |||
38 | private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; | ||
39 | |||
40 | private final Map<String, WeakReference<PushWebDocument>> subscriptions = new HashMap<>(); | ||
41 | |||
42 | private ResponseHandler responseHandler; | ||
43 | |||
44 | private Object callPendingLock = new Object(); | ||
45 | |||
46 | private boolean callPending; | ||
47 | |||
48 | private List<XtextWebPushMessage> pendingPushMessages = new ArrayList<>(); | ||
49 | |||
50 | public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { | ||
51 | this.session = session; | ||
52 | this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; | ||
53 | } | ||
54 | |||
55 | public void setResponseHandler(ResponseHandler responseHandler) { | ||
56 | this.responseHandler = responseHandler; | ||
57 | } | ||
58 | |||
59 | public void handleRequest(XtextWebRequest request) throws ResponseHandlerException { | ||
60 | var serviceContext = new SimpleServiceContext(session, request.getRequestData()); | ||
61 | var ping = serviceContext.getParameter("ping"); | ||
62 | if (ping != null) { | ||
63 | responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping))); | ||
64 | return; | ||
65 | } | ||
66 | synchronized (callPendingLock) { | ||
67 | if (callPending) { | ||
68 | LOG.error("Reentrant request detected"); | ||
69 | } | ||
70 | if (!pendingPushMessages.isEmpty()) { | ||
71 | LOG.error("{} push messages got stuck without a pending request", pendingPushMessages.size()); | ||
72 | } | ||
73 | callPending = true; | ||
74 | } | ||
75 | try { | ||
76 | var injector = getInjector(serviceContext); | ||
77 | var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); | ||
78 | var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this)); | ||
79 | var serviceResult = service.getService().apply(); | ||
80 | responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult)); | ||
81 | } catch (InvalidRequestException e) { | ||
82 | responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e)); | ||
83 | } catch (RuntimeException e) { | ||
84 | responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e)); | ||
85 | } finally { | ||
86 | synchronized (callPendingLock) { | ||
87 | for (var message : pendingPushMessages) { | ||
88 | try { | ||
89 | responseHandler.onResponse(message); | ||
90 | } catch (ResponseHandlerException | RuntimeException e) { | ||
91 | LOG.error("Error while flushing push message", e); | ||
92 | } | ||
93 | } | ||
94 | pendingPushMessages.clear(); | ||
95 | callPending = false; | ||
96 | } | ||
97 | } | ||
98 | } | ||
99 | |||
100 | @Override | ||
101 | public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, | ||
102 | IServiceResult serviceResult) throws ResponseHandlerException { | ||
103 | var message = new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult); | ||
104 | synchronized (callPendingLock) { | ||
105 | // If we're currently responding to a call we must delay any push messages until | ||
106 | // the reply is sent, because push messages relating to the new state id must be | ||
107 | // sent after the response with the new state id so that the client knows about | ||
108 | // the new state when it receives the push message. | ||
109 | if (callPending) { | ||
110 | pendingPushMessages.add(message); | ||
111 | } else { | ||
112 | responseHandler.onResponse(message); | ||
113 | } | ||
114 | } | ||
115 | } | ||
116 | |||
117 | @Override | ||
118 | public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { | ||
119 | PushWebDocument previousDocument = null; | ||
120 | var previousSubscription = subscriptions.get(resourceId); | ||
121 | if (previousSubscription != null) { | ||
122 | previousDocument = previousSubscription.get(); | ||
123 | } | ||
124 | if (previousDocument == document) { | ||
125 | return; | ||
126 | } | ||
127 | if (previousDocument != null) { | ||
128 | previousDocument.removePrecomputationListener(this); | ||
129 | } | ||
130 | subscriptions.put(resourceId, new WeakReference<>(document)); | ||
131 | } | ||
132 | |||
133 | /** | ||
134 | * Get the injector to satisfy the request in the {@code serviceContext}. | ||
135 | * | ||
136 | * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. | ||
137 | * | ||
138 | * @param serviceContext the Xtext service context of the request | ||
139 | * @return the injector for the Xtext language in the request | ||
140 | * @throws UnknownLanguageException if the Xtext language cannot be determined | ||
141 | */ | ||
142 | protected Injector getInjector(IServiceContext context) { | ||
143 | IResourceServiceProvider resourceServiceProvider = null; | ||
144 | var resourceName = context.getParameter("resource"); | ||
145 | if (resourceName == null) { | ||
146 | resourceName = ""; | ||
147 | } | ||
148 | var emfURI = URI.createURI(resourceName); | ||
149 | var contentType = context.getParameter("contentType"); | ||
150 | if (Strings.isNullOrEmpty(contentType)) { | ||
151 | resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); | ||
152 | if (resourceServiceProvider == null) { | ||
153 | if (emfURI.toString().isEmpty()) { | ||
154 | throw new UnknownLanguageException( | ||
155 | "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); | ||
156 | } else { | ||
157 | throw new UnknownLanguageException( | ||
158 | "Unable to identify the Xtext language for resource " + emfURI + "."); | ||
159 | } | ||
160 | } | ||
161 | } else { | ||
162 | resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); | ||
163 | if (resourceServiceProvider == null) { | ||
164 | throw new UnknownLanguageException( | ||
165 | "Unable to identify the Xtext language for contentType " + contentType + "."); | ||
166 | } | ||
167 | } | ||
168 | return resourceServiceProvider.get(Injector.class); | ||
169 | } | ||
170 | |||
171 | @Override | ||
172 | public void dispose() { | ||
173 | for (var subscription : subscriptions.values()) { | ||
174 | var document = subscription.get(); | ||
175 | if (document != null) { | ||
176 | document.removePrecomputationListener(this); | ||
177 | } | ||
178 | } | ||
179 | } | ||
180 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import com.google.gson.annotations.SerializedName; | ||
4 | |||
5 | public enum XtextWebErrorKind { | ||
6 | @SerializedName("request") | ||
7 | REQUEST_ERROR, | ||
8 | |||
9 | @SerializedName("server") | ||
10 | SERVER_ERROR, | ||
11 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public final class XtextWebErrorResponse implements XtextWebResponse { | ||
8 | private String id; | ||
9 | |||
10 | @SerializedName("error") | ||
11 | private XtextWebErrorKind errorKind; | ||
12 | |||
13 | @SerializedName("message") | ||
14 | private String errorMessage; | ||
15 | |||
16 | public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) { | ||
17 | super(); | ||
18 | this.id = id; | ||
19 | this.errorKind = errorKind; | ||
20 | this.errorMessage = errorMessage; | ||
21 | } | ||
22 | |||
23 | public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, | ||
24 | String errorMessage) { | ||
25 | this(request.getId(), errorKind, errorMessage); | ||
26 | } | ||
27 | |||
28 | public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) { | ||
29 | this(request, errorKind, t.getMessage()); | ||
30 | } | ||
31 | |||
32 | public String getId() { | ||
33 | return id; | ||
34 | } | ||
35 | |||
36 | public void setId(String id) { | ||
37 | this.id = id; | ||
38 | } | ||
39 | |||
40 | public XtextWebErrorKind getErrorKind() { | ||
41 | return errorKind; | ||
42 | } | ||
43 | |||
44 | public void setErrorKind(XtextWebErrorKind errorKind) { | ||
45 | this.errorKind = errorKind; | ||
46 | } | ||
47 | |||
48 | public String getErrorMessage() { | ||
49 | return errorMessage; | ||
50 | } | ||
51 | |||
52 | public void setErrorMessage(String errorMessage) { | ||
53 | this.errorMessage = errorMessage; | ||
54 | } | ||
55 | |||
56 | @Override | ||
57 | public int hashCode() { | ||
58 | return Objects.hash(errorKind, errorMessage, id); | ||
59 | } | ||
60 | |||
61 | @Override | ||
62 | public boolean equals(Object obj) { | ||
63 | if (this == obj) | ||
64 | return true; | ||
65 | if (obj == null) | ||
66 | return false; | ||
67 | if (getClass() != obj.getClass()) | ||
68 | return false; | ||
69 | XtextWebErrorResponse other = (XtextWebErrorResponse) obj; | ||
70 | return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) | ||
71 | && Objects.equals(id, other.id); | ||
72 | } | ||
73 | |||
74 | @Override | ||
75 | public String toString() { | ||
76 | return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage | ||
77 | + "]"; | ||
78 | } | ||
79 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
7 | |||
8 | import com.google.gson.annotations.SerializedName; | ||
9 | |||
10 | public final class XtextWebOkResponse implements XtextWebResponse { | ||
11 | private String id; | ||
12 | |||
13 | @SerializedName("response") | ||
14 | private Object responseData; | ||
15 | |||
16 | public XtextWebOkResponse(String id, Object responseData) { | ||
17 | super(); | ||
18 | this.id = id; | ||
19 | this.responseData = responseData; | ||
20 | } | ||
21 | |||
22 | public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) { | ||
23 | this(request.getId(), maybeUnwrap(result)); | ||
24 | } | ||
25 | |||
26 | public String getId() { | ||
27 | return id; | ||
28 | } | ||
29 | |||
30 | public void setId(String id) { | ||
31 | this.id = id; | ||
32 | } | ||
33 | |||
34 | public Object getResponseData() { | ||
35 | return responseData; | ||
36 | } | ||
37 | |||
38 | public void setResponseData(Object responseData) { | ||
39 | this.responseData = responseData; | ||
40 | } | ||
41 | |||
42 | @Override | ||
43 | public int hashCode() { | ||
44 | return Objects.hash(id, responseData); | ||
45 | } | ||
46 | |||
47 | @Override | ||
48 | public boolean equals(Object obj) { | ||
49 | if (this == obj) | ||
50 | return true; | ||
51 | if (obj == null) | ||
52 | return false; | ||
53 | if (getClass() != obj.getClass()) | ||
54 | return false; | ||
55 | XtextWebOkResponse other = (XtextWebOkResponse) obj; | ||
56 | return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData); | ||
57 | } | ||
58 | |||
59 | @Override | ||
60 | public String toString() { | ||
61 | return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]"; | ||
62 | } | ||
63 | |||
64 | private static Object maybeUnwrap(IServiceResult result) { | ||
65 | if (result instanceof IUnwrappableServiceResult unwrappableServiceResult | ||
66 | && unwrappableServiceResult.getContent() != null) { | ||
67 | return unwrappableServiceResult.getContent(); | ||
68 | } else { | ||
69 | return result; | ||
70 | } | ||
71 | } | ||
72 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public final class XtextWebPushMessage implements XtextWebResponse { | ||
8 | @SerializedName("resource") | ||
9 | private String resourceId; | ||
10 | |||
11 | private String stateId; | ||
12 | |||
13 | private String service; | ||
14 | |||
15 | @SerializedName("push") | ||
16 | private Object pushData; | ||
17 | |||
18 | public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) { | ||
19 | super(); | ||
20 | this.resourceId = resourceId; | ||
21 | this.stateId = stateId; | ||
22 | this.service = service; | ||
23 | this.pushData = pushData; | ||
24 | } | ||
25 | |||
26 | public String getResourceId() { | ||
27 | return resourceId; | ||
28 | } | ||
29 | |||
30 | public void setResourceId(String resourceId) { | ||
31 | this.resourceId = resourceId; | ||
32 | } | ||
33 | |||
34 | public String getStateId() { | ||
35 | return stateId; | ||
36 | } | ||
37 | |||
38 | public void setStateId(String stateId) { | ||
39 | this.stateId = stateId; | ||
40 | } | ||
41 | |||
42 | public String getService() { | ||
43 | return service; | ||
44 | } | ||
45 | |||
46 | public void setService(String service) { | ||
47 | this.service = service; | ||
48 | } | ||
49 | |||
50 | public Object getPushData() { | ||
51 | return pushData; | ||
52 | } | ||
53 | |||
54 | public void setPushData(Object pushData) { | ||
55 | this.pushData = pushData; | ||
56 | } | ||
57 | |||
58 | @Override | ||
59 | public int hashCode() { | ||
60 | return Objects.hash(pushData, resourceId, service, stateId); | ||
61 | } | ||
62 | |||
63 | @Override | ||
64 | public boolean equals(Object obj) { | ||
65 | if (this == obj) | ||
66 | return true; | ||
67 | if (obj == null) | ||
68 | return false; | ||
69 | if (getClass() != obj.getClass()) | ||
70 | return false; | ||
71 | XtextWebPushMessage other = (XtextWebPushMessage) obj; | ||
72 | return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId) | ||
73 | && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId); | ||
74 | } | ||
75 | |||
76 | @Override | ||
77 | public String toString() { | ||
78 | return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service | ||
79 | + ", pushData=" + pushData + "]"; | ||
80 | } | ||
81 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Objects; | ||
5 | |||
6 | import com.google.gson.annotations.SerializedName; | ||
7 | |||
8 | public class XtextWebRequest { | ||
9 | private String id; | ||
10 | |||
11 | @SerializedName("request") | ||
12 | private Map<String, String> requestData; | ||
13 | |||
14 | public XtextWebRequest(String id, Map<String, String> requestData) { | ||
15 | super(); | ||
16 | this.id = id; | ||
17 | this.requestData = requestData; | ||
18 | } | ||
19 | |||
20 | public String getId() { | ||
21 | return id; | ||
22 | } | ||
23 | |||
24 | public void setId(String id) { | ||
25 | this.id = id; | ||
26 | } | ||
27 | |||
28 | public Map<String, String> getRequestData() { | ||
29 | return requestData; | ||
30 | } | ||
31 | |||
32 | public void setRequestData(Map<String, String> requestData) { | ||
33 | this.requestData = requestData; | ||
34 | } | ||
35 | |||
36 | @Override | ||
37 | public int hashCode() { | ||
38 | return Objects.hash(id, requestData); | ||
39 | } | ||
40 | |||
41 | @Override | ||
42 | public boolean equals(Object obj) { | ||
43 | if (this == obj) | ||
44 | return true; | ||
45 | if (obj == null) | ||
46 | return false; | ||
47 | if (getClass() != obj.getClass()) | ||
48 | return false; | ||
49 | XtextWebRequest other = (XtextWebRequest) obj; | ||
50 | return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData); | ||
51 | } | ||
52 | |||
53 | @Override | ||
54 | public String toString() { | ||
55 | return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]"; | ||
56 | } | ||
57 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | public 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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceResult; | ||
4 | |||
5 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
6 | |||
7 | @FunctionalInterface | ||
8 | public interface PrecomputationListener { | ||
9 | void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult) | ||
10 | throws ResponseHandlerException; | ||
11 | |||
12 | default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) { | ||
13 | // Nothing to handle by default. | ||
14 | } | ||
15 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
5 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
6 | |||
7 | import com.google.inject.Singleton; | ||
8 | |||
9 | import tools.refinery.language.web.xtext.server.SubscribingServiceContext; | ||
10 | |||
11 | @Singleton | ||
12 | public class PushServiceDispatcher extends XtextServiceDispatcher { | ||
13 | |||
14 | @Override | ||
15 | protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) { | ||
16 | var document = super.getFullTextDocument(fullText, resourceId, context); | ||
17 | if (document instanceof PushWebDocument pushWebDocument | ||
18 | && context instanceof SubscribingServiceContext subscribingContext) { | ||
19 | pushWebDocument.addPrecomputationListener(subscribingContext.subscriber()); | ||
20 | } | ||
21 | return document; | ||
22 | } | ||
23 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import java.util.ArrayList; | ||
4 | import java.util.HashMap; | ||
5 | import java.util.List; | ||
6 | import java.util.Map; | ||
7 | |||
8 | import org.eclipse.xtext.util.CancelIndicator; | ||
9 | import org.eclipse.xtext.web.server.IServiceResult; | ||
10 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
11 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
12 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import com.google.common.collect.ImmutableList; | ||
17 | |||
18 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
19 | |||
20 | public class PushWebDocument extends XtextWebDocument { | ||
21 | private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class); | ||
22 | |||
23 | private final List<PrecomputationListener> precomputationListeners = new ArrayList<>(); | ||
24 | |||
25 | private final Map<Class<?>, IServiceResult> precomputedServices = new HashMap<>(); | ||
26 | |||
27 | public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) { | ||
28 | super(resourceId, synchronizer); | ||
29 | if (resourceId == null) { | ||
30 | throw new IllegalArgumentException("resourceId must not be null"); | ||
31 | } | ||
32 | } | ||
33 | |||
34 | public boolean addPrecomputationListener(PrecomputationListener listener) { | ||
35 | synchronized (precomputationListeners) { | ||
36 | if (precomputationListeners.contains(listener)) { | ||
37 | return false; | ||
38 | } | ||
39 | precomputationListeners.add(listener); | ||
40 | listener.onSubscribeToPrecomputationEvents(getResourceId(), this); | ||
41 | return true; | ||
42 | } | ||
43 | } | ||
44 | |||
45 | public boolean removePrecomputationListener(PrecomputationListener listener) { | ||
46 | synchronized (precomputationListeners) { | ||
47 | return precomputationListeners.remove(listener); | ||
48 | } | ||
49 | } | ||
50 | |||
51 | public <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, String serviceName, | ||
52 | CancelIndicator cancelIndicator, boolean logCacheMiss) { | ||
53 | var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss); | ||
54 | if (result == null) { | ||
55 | LOG.error("{} service returned null result", serviceName); | ||
56 | return; | ||
57 | } | ||
58 | var serviceClass = service.getClass(); | ||
59 | var previousResult = precomputedServices.get(serviceClass); | ||
60 | if (previousResult != null && previousResult.equals(result)) { | ||
61 | return; | ||
62 | } | ||
63 | precomputedServices.put(serviceClass, result); | ||
64 | notifyPrecomputationListeners(serviceName, result); | ||
65 | } | ||
66 | |||
67 | private <T extends IServiceResult> void notifyPrecomputationListeners(String serviceName, T result) { | ||
68 | var resourceId = getResourceId(); | ||
69 | var stateId = getStateId(); | ||
70 | List<PrecomputationListener> copyOfListeners; | ||
71 | synchronized (precomputationListeners) { | ||
72 | copyOfListeners = ImmutableList.copyOf(precomputationListeners); | ||
73 | } | ||
74 | var toRemove = new ArrayList<PrecomputationListener>(); | ||
75 | for (var listener : copyOfListeners) { | ||
76 | try { | ||
77 | listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result); | ||
78 | } catch (ResponseHandlerException e) { | ||
79 | LOG.error("Delivering precomputation push message failed", e); | ||
80 | toRemove.add(listener); | ||
81 | } | ||
82 | } | ||
83 | if (!toRemove.isEmpty()) { | ||
84 | synchronized (precomputationListeners) { | ||
85 | precomputationListeners.removeAll(toRemove); | ||
86 | } | ||
87 | } | ||
88 | } | ||
89 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.service.OperationCanceledManager; | ||
4 | import org.eclipse.xtext.util.CancelIndicator; | ||
5 | import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork; | ||
6 | import org.eclipse.xtext.web.server.IServiceResult; | ||
7 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
8 | import org.eclipse.xtext.web.server.model.IXtextWebDocument; | ||
9 | import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; | ||
10 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
11 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
12 | import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService; | ||
13 | import org.eclipse.xtext.web.server.validation.ValidationService; | ||
14 | |||
15 | import com.google.inject.Inject; | ||
16 | |||
17 | public class PushWebDocumentAccess extends XtextWebDocumentAccess { | ||
18 | |||
19 | @Inject | ||
20 | private PrecomputedServiceRegistry preComputedServiceRegistry; | ||
21 | |||
22 | @Inject | ||
23 | private OperationCanceledManager operationCanceledManager; | ||
24 | |||
25 | private PushWebDocument pushDocument; | ||
26 | |||
27 | @Override | ||
28 | protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) { | ||
29 | super.init(document, requiredStateId, skipAsyncWork); | ||
30 | if (document instanceof PushWebDocument newPushDocument) { | ||
31 | pushDocument = newPushDocument; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | @Override | ||
36 | protected void performPrecomputation(CancelIndicator cancelIndicator) { | ||
37 | if (pushDocument == null) { | ||
38 | super.performPrecomputation(cancelIndicator); | ||
39 | return; | ||
40 | } | ||
41 | for (AbstractCachedService<? extends IServiceResult> service : preComputedServiceRegistry | ||
42 | .getPrecomputedServices()) { | ||
43 | operationCanceledManager.checkCanceled(cancelIndicator); | ||
44 | precomputeServiceResult(service, false); | ||
45 | } | ||
46 | } | ||
47 | |||
48 | protected <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, boolean logCacheMiss) { | ||
49 | var serviceName = getPrecomputedServiceName(service); | ||
50 | readOnly(new CancelableUnitOfWork<Void, IXtextWebDocument>() { | ||
51 | @Override | ||
52 | public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception { | ||
53 | pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss); | ||
54 | return null; | ||
55 | } | ||
56 | }); | ||
57 | } | ||
58 | |||
59 | protected String getPrecomputedServiceName(AbstractCachedService<? extends IServiceResult> service) { | ||
60 | if (service instanceof ValidationService) { | ||
61 | return "validate"; | ||
62 | } | ||
63 | if (service instanceof HighlightingService) { | ||
64 | return "highlight"; | ||
65 | } | ||
66 | throw new IllegalArgumentException("Unknown precomputed service: " + service); | ||
67 | } | ||
68 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
5 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
6 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
7 | |||
8 | import com.google.inject.Inject; | ||
9 | import com.google.inject.Provider; | ||
10 | import com.google.inject.Singleton; | ||
11 | |||
12 | /** | ||
13 | * Based on | ||
14 | * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}. | ||
15 | * | ||
16 | * @author Kristóf Marussy | ||
17 | */ | ||
18 | @Singleton | ||
19 | public class PushWebDocumentProvider implements IWebDocumentProvider { | ||
20 | @Inject | ||
21 | private Provider<DocumentSynchronizer> synchronizerProvider; | ||
22 | |||
23 | @Override | ||
24 | public XtextWebDocument get(String resourceId, IServiceContext serviceContext) { | ||
25 | if (resourceId == null) { | ||
26 | return new XtextWebDocument(resourceId, synchronizerProvider.get()); | ||
27 | } else { | ||
28 | // We only need to send push messages if a resourceId is specified. | ||
29 | return new PushWebDocument(resourceId, | ||
30 | serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get())); | ||
31 | } | ||
32 | } | ||
33 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Set; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.IServiceContext; | ||
7 | import org.eclipse.xtext.web.server.ISession; | ||
8 | |||
9 | import com.google.common.collect.ImmutableSet; | ||
10 | |||
11 | public record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { | ||
12 | @Override | ||
13 | public Set<String> getParameterKeys() { | ||
14 | return ImmutableSet.copyOf(parameters.keySet()); | ||
15 | } | ||
16 | |||
17 | @Override | ||
18 | public String getParameter(String key) { | ||
19 | return parameters.get(key); | ||
20 | } | ||
21 | |||
22 | @Override | ||
23 | public ISession getSession() { | ||
24 | return session; | ||
25 | } | ||
26 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.HashMap; | ||
4 | import java.util.Map; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | import org.eclipse.xtext.xbase.lib.Functions.Function0; | ||
8 | |||
9 | public class SimpleSession implements ISession { | ||
10 | private Map<Object, Object> map = new HashMap<>(); | ||
11 | |||
12 | @Override | ||
13 | public <T> T get(Object key) { | ||
14 | @SuppressWarnings("unchecked") | ||
15 | var value = (T) map.get(key); | ||
16 | return value; | ||
17 | } | ||
18 | |||
19 | @Override | ||
20 | public <T> T get(Object key, Function0<? extends T> factory) { | ||
21 | @SuppressWarnings("unchecked") | ||
22 | var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply()); | ||
23 | return value; | ||
24 | } | ||
25 | |||
26 | @Override | ||
27 | public void put(Object key, Object value) { | ||
28 | map.put(key, value); | ||
29 | } | ||
30 | |||
31 | @Override | ||
32 | public void remove(Object key) { | ||
33 | map.remove(key); | ||
34 | } | ||
35 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | public final class XtextStatusCode { | ||
4 | public static final int INVALID_JSON = 4007; | ||
5 | |||
6 | private XtextStatusCode() { | ||
7 | throw new IllegalStateException("This is a static utility class and should not be instantiated directly"); | ||
8 | } | ||
9 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.io.Reader; | ||
5 | |||
6 | import org.eclipse.jetty.websocket.api.Session; | ||
7 | import org.eclipse.jetty.websocket.api.StatusCode; | ||
8 | import org.eclipse.jetty.websocket.api.WriteCallback; | ||
9 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; | ||
10 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; | ||
12 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
13 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
14 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
15 | import org.eclipse.xtext.web.server.ISession; | ||
16 | import org.slf4j.Logger; | ||
17 | import org.slf4j.LoggerFactory; | ||
18 | |||
19 | import com.google.gson.Gson; | ||
20 | import com.google.gson.JsonIOException; | ||
21 | import com.google.gson.JsonParseException; | ||
22 | |||
23 | import tools.refinery.language.web.xtext.server.ResponseHandler; | ||
24 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
25 | import tools.refinery.language.web.xtext.server.TransactionExecutor; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
28 | |||
29 | @WebSocket | ||
30 | public class XtextWebSocket implements WriteCallback, ResponseHandler { | ||
31 | private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); | ||
32 | |||
33 | private final Gson gson = new Gson(); | ||
34 | |||
35 | private final TransactionExecutor executor; | ||
36 | |||
37 | private Session webSocketSession; | ||
38 | |||
39 | public XtextWebSocket(TransactionExecutor executor) { | ||
40 | this.executor = executor; | ||
41 | executor.setResponseHandler(this); | ||
42 | } | ||
43 | |||
44 | public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { | ||
45 | this(new TransactionExecutor(session, resourceServiceProviderRegistry)); | ||
46 | } | ||
47 | |||
48 | @OnWebSocketConnect | ||
49 | public void onConnect(Session webSocketSession) { | ||
50 | if (this.webSocketSession != null) { | ||
51 | LOG.error("Websocket session onConnect when already connected"); | ||
52 | return; | ||
53 | } | ||
54 | LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); | ||
55 | this.webSocketSession = webSocketSession; | ||
56 | } | ||
57 | |||
58 | @OnWebSocketClose | ||
59 | public void onClose(int statusCode, String reason) { | ||
60 | executor.dispose(); | ||
61 | if (webSocketSession == null) { | ||
62 | return; | ||
63 | } | ||
64 | if (statusCode == StatusCode.NORMAL || statusCode == StatusCode.SHUTDOWN) { | ||
65 | LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); | ||
66 | } else { | ||
67 | LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, | ||
68 | reason); | ||
69 | } | ||
70 | webSocketSession = null; | ||
71 | } | ||
72 | |||
73 | @OnWebSocketError | ||
74 | public void onError(Throwable error) { | ||
75 | if (webSocketSession == null) { | ||
76 | return; | ||
77 | } | ||
78 | LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); | ||
79 | } | ||
80 | |||
81 | @OnWebSocketMessage | ||
82 | public void onMessage(Reader reader) { | ||
83 | if (webSocketSession == null) { | ||
84 | LOG.error("Trying to receive message when websocket is disconnected"); | ||
85 | return; | ||
86 | } | ||
87 | XtextWebRequest request; | ||
88 | try { | ||
89 | request = gson.fromJson(reader, XtextWebRequest.class); | ||
90 | } catch (JsonIOException e) { | ||
91 | LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); | ||
92 | if (webSocketSession.isOpen()) { | ||
93 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); | ||
94 | } | ||
95 | return; | ||
96 | } catch (JsonParseException e) { | ||
97 | LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); | ||
98 | webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); | ||
99 | return; | ||
100 | } | ||
101 | try { | ||
102 | executor.handleRequest(request); | ||
103 | } catch (ResponseHandlerException e) { | ||
104 | LOG.warn("Cannot write websocket response", e); | ||
105 | if (webSocketSession.isOpen()) { | ||
106 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response"); | ||
107 | } | ||
108 | } | ||
109 | } | ||
110 | |||
111 | @Override | ||
112 | public void onResponse(XtextWebResponse response) throws ResponseHandlerException { | ||
113 | if (webSocketSession == null) { | ||
114 | throw new ResponseHandlerException("Trying to send message when websocket is disconnected"); | ||
115 | } | ||
116 | var responseString = gson.toJson(response); | ||
117 | try { | ||
118 | webSocketSession.getRemote().sendPartialString(responseString, true, this); | ||
119 | } catch (IOException e) { | ||
120 | throw new ResponseHandlerException( | ||
121 | "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); | ||
122 | } | ||
123 | } | ||
124 | |||
125 | @Override | ||
126 | public void writeFailed(Throwable x) { | ||
127 | if (webSocketSession == null) { | ||
128 | LOG.error("Cannot complete async write to disconnected websocket", x); | ||
129 | return; | ||
130 | } | ||
131 | LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); | ||
132 | } | ||
133 | } | ||
diff --git a/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 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.time.Duration; | ||
5 | import java.util.Set; | ||
6 | |||
7 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; | ||
8 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; | ||
9 | import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; | ||
10 | import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; | ||
11 | import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; | ||
12 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import jakarta.servlet.ServletConfig; | ||
17 | import jakarta.servlet.ServletException; | ||
18 | |||
19 | public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator { | ||
20 | |||
21 | private static final long serialVersionUID = -3772740838165122685L; | ||
22 | |||
23 | public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; | ||
24 | |||
25 | public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin"; | ||
26 | |||
27 | public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1"; | ||
28 | |||
29 | /** | ||
30 | * Maximum message size should be large enough to upload a full model file. | ||
31 | */ | ||
32 | private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; | ||
33 | |||
34 | private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30); | ||
35 | |||
36 | private transient Logger log = LoggerFactory.getLogger(getClass()); | ||
37 | |||
38 | private transient Set<String> allowedOrigins = null; | ||
39 | |||
40 | @Override | ||
41 | public void init(ServletConfig config) throws ServletException { | ||
42 | var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); | ||
43 | if (allowedOriginsStr == null) { | ||
44 | log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); | ||
45 | } else { | ||
46 | allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); | ||
47 | log.info("Allowed origins: {}", allowedOrigins); | ||
48 | } | ||
49 | super.init(config); | ||
50 | } | ||
51 | |||
52 | @Override | ||
53 | protected void configure(JettyWebSocketServletFactory factory) { | ||
54 | factory.setMaxFrameSize(MAX_FRAME_SIZE); | ||
55 | factory.setIdleTimeout(IDLE_TIMEOUT); | ||
56 | factory.addMapping("/", this); | ||
57 | } | ||
58 | |||
59 | @Override | ||
60 | public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { | ||
61 | if (allowedOrigins != null) { | ||
62 | var origin = req.getOrigin(); | ||
63 | if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) { | ||
64 | log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); | ||
65 | try { | ||
66 | resp.sendForbidden("Origin not allowed"); | ||
67 | } catch (IOException e) { | ||
68 | log.error("Cannot send forbidden origin error", e); | ||
69 | } | ||
70 | return null; | ||
71 | } | ||
72 | } | ||
73 | if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) { | ||
74 | resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1); | ||
75 | } else { | ||
76 | log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(), | ||
77 | req.getRemoteSocketAddress()); | ||
78 | resp.setAcceptedSubProtocol(null); | ||
79 | } | ||
80 | var session = new SimpleSession(); | ||
81 | return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); | ||
82 | } | ||
83 | } | ||
diff --git a/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 @@ | |||
1 | import AppBar from '@mui/material/AppBar'; | 1 | import AppBar from '@mui/material/AppBar'; |
2 | import Box from '@mui/material/Box'; | 2 | import Box from '@mui/material/Box'; |
3 | import Button from '@mui/material/Button'; | ||
4 | import IconButton from '@mui/material/IconButton'; | 3 | import IconButton from '@mui/material/IconButton'; |
5 | import Toolbar from '@mui/material/Toolbar'; | 4 | import Toolbar from '@mui/material/Toolbar'; |
6 | import Typography from '@mui/material/Typography'; | 5 | import Typography from '@mui/material/Typography'; |
7 | import MenuIcon from '@mui/icons-material/Menu'; | 6 | import MenuIcon from '@mui/icons-material/Menu'; |
8 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; | ||
9 | import React from 'react'; | 7 | import React from 'react'; |
10 | 8 | ||
11 | import { EditorArea } from './editor/EditorArea'; | 9 | import { EditorArea } from './editor/EditorArea'; |
12 | import { EditorButtons } from './editor/EditorButtons'; | 10 | import { EditorButtons } from './editor/EditorButtons'; |
11 | import { GenerateButton } from './editor/GenerateButton'; | ||
13 | 12 | ||
14 | export const App = (): JSX.Element => ( | 13 | export 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 @@ | |||
1 | import { Command, EditorView } from '@codemirror/view'; | ||
2 | import { closeSearchPanel, openSearchPanel } from '@codemirror/search'; | ||
3 | import { closeLintPanel, openLintPanel } from '@codemirror/lint'; | ||
1 | import { observer } from 'mobx-react-lite'; | 4 | import { observer } from 'mobx-react-lite'; |
2 | import React, { useRef } from 'react'; | 5 | import React, { |
6 | useCallback, | ||
7 | useEffect, | ||
8 | useRef, | ||
9 | useState, | ||
10 | } from 'react'; | ||
3 | 11 | ||
12 | import { EditorParent } from './EditorParent'; | ||
4 | import { useRootStore } from '../RootStore'; | 13 | import { useRootStore } from '../RootStore'; |
14 | import { getLogger } from '../utils/logger'; | ||
15 | |||
16 | const log = getLogger('editor.EditorArea'); | ||
17 | |||
18 | function usePanel( | ||
19 | panelId: string, | ||
20 | stateToSet: boolean, | ||
21 | editorView: EditorView | null, | ||
22 | openCommand: Command, | ||
23 | closeCommand: Command, | ||
24 | closeCallback: () => void, | ||
25 | ) { | ||
26 | const [cachedViewState, setCachedViewState] = useState<boolean>(false); | ||
27 | useEffect(() => { | ||
28 | if (editorView === null || cachedViewState === stateToSet) { | ||
29 | return; | ||
30 | } | ||
31 | if (stateToSet) { | ||
32 | openCommand(editorView); | ||
33 | const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`; | ||
34 | const closeButton = editorView.dom.querySelector(buttonQuery); | ||
35 | if (closeButton) { | ||
36 | log.debug('Addig close button callback to', panelId, 'panel'); | ||
37 | // We must remove the event listener added by CodeMirror from the button | ||
38 | // that dispatches a transaction without going through `EditorStorre`. | ||
39 | // Cloning a DOM node removes event listeners, | ||
40 | // see https://stackoverflow.com/a/9251864 | ||
41 | const closeButtonWithoutListeners = closeButton.cloneNode(true); | ||
42 | closeButtonWithoutListeners.addEventListener('click', (event) => { | ||
43 | closeCallback(); | ||
44 | event.preventDefault(); | ||
45 | }); | ||
46 | closeButton.replaceWith(closeButtonWithoutListeners); | ||
47 | } else { | ||
48 | log.error('Opened', panelId, 'panel has no close button'); | ||
49 | } | ||
50 | } else { | ||
51 | closeCommand(editorView); | ||
52 | } | ||
53 | setCachedViewState(stateToSet); | ||
54 | }, [ | ||
55 | stateToSet, | ||
56 | editorView, | ||
57 | cachedViewState, | ||
58 | panelId, | ||
59 | openCommand, | ||
60 | closeCommand, | ||
61 | closeCallback, | ||
62 | ]); | ||
63 | return setCachedViewState; | ||
64 | } | ||
65 | |||
66 | function fixCodeMirrorAccessibility(editorView: EditorView) { | ||
67 | // Reported by Lighthouse 8.3.0. | ||
68 | const { contentDOM } = editorView; | ||
69 | contentDOM.removeAttribute('aria-expanded'); | ||
70 | contentDOM.setAttribute('aria-label', 'Code editor'); | ||
71 | } | ||
5 | 72 | ||
6 | export const EditorArea = observer(() => { | 73 | export 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 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
1 | import { observer } from 'mobx-react-lite'; | 2 | import { observer } from 'mobx-react-lite'; |
3 | import IconButton from '@mui/material/IconButton'; | ||
2 | import Stack from '@mui/material/Stack'; | 4 | import Stack from '@mui/material/Stack'; |
3 | import ToggleButton from '@mui/material/ToggleButton'; | 5 | import ToggleButton from '@mui/material/ToggleButton'; |
4 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | 6 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; |
7 | import CheckIcon from '@mui/icons-material/Check'; | ||
8 | import ErrorIcon from '@mui/icons-material/Error'; | ||
5 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | 9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; |
10 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | ||
6 | import RedoIcon from '@mui/icons-material/Redo'; | 11 | import RedoIcon from '@mui/icons-material/Redo'; |
12 | import SearchIcon from '@mui/icons-material/Search'; | ||
7 | import UndoIcon from '@mui/icons-material/Undo'; | 13 | import UndoIcon from '@mui/icons-material/Undo'; |
14 | import WarningIcon from '@mui/icons-material/Warning'; | ||
8 | import React from 'react'; | 15 | import React from 'react'; |
9 | 16 | ||
10 | import { useRootStore } from '../RootStore'; | 17 | import { useRootStore } from '../RootStore'; |
11 | 18 | ||
19 | // Exhastive switch as proven by TypeScript. | ||
20 | // eslint-disable-next-line consistent-return | ||
21 | function 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 | |||
12 | export const EditorButtons = observer(() => { | 34 | export 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 @@ | |||
1 | import { styled } from '@mui/material/styles'; | ||
2 | |||
3 | /** | ||
4 | * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64. | ||
5 | * | ||
6 | * Based on | ||
7 | * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501 | ||
8 | * | ||
9 | * @param color the color of the underline | ||
10 | * @returns the CSS `url()` | ||
11 | */ | ||
12 | function underline(color: string) { | ||
13 | const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3"> | ||
14 | <path d="m0 3 l2 -2 l1 0 l2 2 l1 0" stroke="${color}" fill="none" stroke-width=".7"/> | ||
15 | </svg>`; | ||
16 | const svgBase64 = window.btoa(svg); | ||
17 | return `url('data:image/svg+xml;base64,${svgBase64}')`; | ||
18 | } | ||
19 | |||
20 | export const EditorParent = styled('div')(({ theme }) => { | ||
21 | const codeMirrorLintStyle: Record<string, unknown> = {}; | ||
22 | (['error', 'warning', 'info'] as const).forEach((severity) => { | ||
23 | const color = theme.palette[severity].main; | ||
24 | codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = { | ||
25 | borderLeftColor: color, | ||
26 | }; | ||
27 | codeMirrorLintStyle[`.cm-lintRange-${severity}`] = { | ||
28 | backgroundImage: underline(color), | ||
29 | }; | ||
30 | }); | ||
31 | |||
32 | return { | ||
33 | background: theme.palette.background.default, | ||
34 | '&, .cm-editor': { | ||
35 | height: '100%', | ||
36 | }, | ||
37 | '.cm-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 @@ | |||
1 | import type { Editor, EditorConfiguration } from 'codemirror'; | 1 | import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; |
2 | import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; | ||
3 | import { defaultKeymap, indentWithTab } from '@codemirror/commands'; | ||
4 | import { commentKeymap } from '@codemirror/comment'; | ||
5 | import { foldGutter, foldKeymap } from '@codemirror/fold'; | ||
6 | import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter'; | ||
7 | import { classHighlightStyle } from '@codemirror/highlight'; | ||
8 | import { | ||
9 | history, | ||
10 | historyKeymap, | ||
11 | redo, | ||
12 | redoDepth, | ||
13 | undo, | ||
14 | undoDepth, | ||
15 | } from '@codemirror/history'; | ||
16 | import { indentOnInput } from '@codemirror/language'; | ||
17 | import { | ||
18 | Diagnostic, | ||
19 | lintKeymap, | ||
20 | setDiagnostics, | ||
21 | } from '@codemirror/lint'; | ||
22 | import { bracketMatching } from '@codemirror/matchbrackets'; | ||
23 | import { rectangularSelection } from '@codemirror/rectangular-selection'; | ||
24 | import { searchConfig, searchKeymap } from '@codemirror/search'; | ||
25 | import { | ||
26 | EditorState, | ||
27 | StateCommand, | ||
28 | StateEffect, | ||
29 | Transaction, | ||
30 | TransactionSpec, | ||
31 | } from '@codemirror/state'; | ||
32 | import { | ||
33 | drawSelection, | ||
34 | EditorView, | ||
35 | highlightActiveLine, | ||
36 | highlightSpecialChars, | ||
37 | keymap, | ||
38 | } from '@codemirror/view'; | ||
2 | import { | 39 | import { |
3 | createAtom, | ||
4 | makeAutoObservable, | 40 | makeAutoObservable, |
5 | observable, | 41 | observable, |
6 | runInAction, | 42 | reaction, |
7 | } from 'mobx'; | 43 | } from 'mobx'; |
8 | import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror'; | ||
9 | 44 | ||
10 | import type { IEditorChunk } from './editor'; | 45 | import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; |
11 | import { getLogger } from '../logging'; | 46 | import { problemLanguageSupport } from '../language/problemLanguageSupport'; |
47 | import { | ||
48 | IHighlightRange, | ||
49 | semanticHighlighting, | ||
50 | setSemanticHighlighting, | ||
51 | } from './semanticHighlighting'; | ||
12 | import type { ThemeStore } from '../theme/ThemeStore'; | 52 | import type { ThemeStore } from '../theme/ThemeStore'; |
53 | import { getLogger } from '../utils/logger'; | ||
54 | import { XtextClient } from '../xtext/XtextClient'; | ||
13 | 55 | ||
14 | const log = getLogger('EditorStore'); | 56 | const log = getLogger('editor.EditorStore'); |
15 | 57 | ||
16 | const xtextLang = 'problem'; | 58 | export class EditorStore { |
59 | private readonly themeStore; | ||
17 | 60 | ||
18 | const xtextOptions: IXtextOptions = { | 61 | state: EditorState; |
19 | xtextLang, | ||
20 | enableFormattingAction: true, | ||
21 | }; | ||
22 | 62 | ||
23 | const 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 | ||
31 | export 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 @@ | |||
1 | import { observer } from 'mobx-react-lite'; | ||
2 | import Button from '@mui/material/Button'; | ||
3 | import PlayArrowIcon from '@mui/icons-material/PlayArrow'; | ||
4 | import React from 'react'; | ||
5 | |||
6 | import { useRootStore } from '../RootStore'; | ||
7 | |||
8 | const GENERATE_LABEL = 'Generate'; | ||
9 | |||
10 | export const GenerateButton = observer(() => { | ||
11 | const { editorStore } = useRootStore(); | ||
12 | const { errorCount, warningCount } = editorStore; | ||
13 | |||
14 | const diagnostics: string[] = []; | ||
15 | if (errorCount > 0) { | ||
16 | diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`); | ||
17 | } | ||
18 | if (warningCount > 0) { | ||
19 | diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`); | ||
20 | } | ||
21 | const summary = diagnostics.join(' and '); | ||
22 | |||
23 | if (errorCount > 0) { | ||
24 | return ( | ||
25 | <Button | ||
26 | variant="outlined" | ||
27 | color="error" | ||
28 | onClick={() => editorStore.toggleLintPanel()} | ||
29 | > | ||
30 | {summary} | ||
31 | </Button> | ||
32 | ); | ||
33 | } | ||
34 | |||
35 | return ( | ||
36 | <Button | ||
37 | variant="outlined" | ||
38 | color={warningCount > 0 ? 'warning' : 'primary'} | ||
39 | startIcon={<PlayArrowIcon />} | ||
40 | > | ||
41 | {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`} | ||
42 | </Button> | ||
43 | ); | ||
44 | }); | ||
diff --git a/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 @@ | |||
1 | import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; | ||
2 | import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; | ||
3 | |||
4 | export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; | ||
5 | |||
6 | export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] { | ||
7 | const setEffect = StateEffect.define<DecorationSet>(); | ||
8 | const field = StateField.define<DecorationSet>({ | ||
9 | create() { | ||
10 | return Decoration.none; | ||
11 | }, | ||
12 | update(currentDecorations, transaction) { | ||
13 | let newDecorations: DecorationSet | null = null; | ||
14 | transaction.effects.forEach((effect) => { | ||
15 | if (effect.is(setEffect)) { | ||
16 | newDecorations = effect.value; | ||
17 | } | ||
18 | }); | ||
19 | if (newDecorations === null) { | ||
20 | if (transaction.docChanged) { | ||
21 | return currentDecorations.map(transaction.changes); | ||
22 | } | ||
23 | return currentDecorations; | ||
24 | } | ||
25 | return newDecorations; | ||
26 | }, | ||
27 | provide: (f) => EditorView.decorations.from(f), | ||
28 | }); | ||
29 | |||
30 | function transactionSpecFactory(decorations: DecorationSet) { | ||
31 | return { | ||
32 | effects: [ | ||
33 | setEffect.of(decorations), | ||
34 | ], | ||
35 | }; | ||
36 | } | ||
37 | |||
38 | return [transactionSpecFactory, field]; | ||
39 | } | ||
diff --git a/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 @@ | |||
1 | import 'codemirror/addon/selection/active-line'; | ||
2 | import 'mode-problem'; | ||
3 | import { Controlled } from 'react-codemirror2'; | ||
4 | import { createServices, removeServices } from 'xtext/xtext-codemirror'; | ||
5 | |||
6 | export interface IEditorChunk { | ||
7 | CodeMirror: typeof Controlled; | ||
8 | |||
9 | createServices: typeof createServices; | ||
10 | |||
11 | removeServices: typeof removeServices; | ||
12 | } | ||
13 | |||
14 | export 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 @@ | |||
1 | import { Range, RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IOccurrence { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | } | ||
12 | |||
13 | const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); | ||
14 | |||
15 | const writeDecoration = Decoration.mark({ | ||
16 | class: 'cm-problem-write', | ||
17 | }); | ||
18 | |||
19 | const readDecoration = Decoration.mark({ | ||
20 | class: 'cm-problem-read', | ||
21 | }); | ||
22 | |||
23 | export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec { | ||
24 | const decorations: Range<Decoration>[] = []; | ||
25 | write.forEach(({ from, to }) => { | ||
26 | decorations.push(writeDecoration.range(from, to)); | ||
27 | }); | ||
28 | read.forEach(({ from, to }) => { | ||
29 | decorations.push(readDecoration.range(from, to)); | ||
30 | }); | ||
31 | const rangeSet = RangeSet.of(decorations, true); | ||
32 | return setOccurrencesInteral(rangeSet); | ||
33 | } | ||
34 | |||
35 | export { findOccurrences }; | ||
diff --git a/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 @@ | |||
1 | import { RangeSet } from '@codemirror/rangeset'; | ||
2 | import type { TransactionSpec } from '@codemirror/state'; | ||
3 | import { Decoration } from '@codemirror/view'; | ||
4 | |||
5 | import { decorationSetExtension } from './decorationSetExtension'; | ||
6 | |||
7 | export interface IHighlightRange { | ||
8 | from: number; | ||
9 | |||
10 | to: number; | ||
11 | |||
12 | classes: string[]; | ||
13 | } | ||
14 | |||
15 | const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); | ||
16 | |||
17 | export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec { | ||
18 | const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({ | ||
19 | class: classes.map((c) => `cmt-problem-${c}`).join(' '), | ||
20 | }).range(from, to)), true); | ||
21 | return setSemanticHighlightingInternal(rangeSet); | ||
22 | } | ||
23 | |||
24 | export { semanticHighlighting }; | ||
diff --git a/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'; | |||
9 | import '../css/index.scss'; | 9 | import '../css/index.scss'; |
10 | 10 | ||
11 | const initialValue = `class Family { | 11 | const initialValue = `class Family { |
12 | contains Person[] members | 12 | contains Person[] members |
13 | } | 13 | } |
14 | 14 | ||
15 | class Person { | 15 | class 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 | ||
22 | enum TaxStatus { | 22 | enum 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. |
27 | error invalidTaxStatus(Person p) <-> | 27 | pred 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 | |||
34 | direct rule createChild(p): | ||
35 | children(p, newPerson) = unknown, | ||
36 | equals(newPerson, newPerson) = unknown | ||
37 | ~> new q, | ||
38 | children(p, q) = true, | ||
39 | taxStatus(q, child) = true. | ||
29 | 40 | ||
30 | unique family. | 41 | unique family. |
31 | Family(family). | 42 | Family(family). |
@@ -44,8 +55,7 @@ age(bob, bobAge). | |||
44 | scope Family = 1, Person += 5..10. | 55 | scope Family = 1, Person += 5..10. |
45 | `; | 56 | `; |
46 | 57 | ||
47 | const rootStore = new RootStore(); | 58 | const rootStore = new RootStore(initialValue); |
48 | rootStore.editorStore.updateValue(initialValue); | ||
49 | 59 | ||
50 | const app = ( | 60 | const 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 @@ | |||
1 | import { EditorState } from '@codemirror/state'; | ||
2 | import type { SyntaxNode } from '@lezer/common'; | ||
3 | |||
4 | export type FoldRange = { from: number, to: number }; | ||
5 | |||
6 | /** | ||
7 | * Folds a block comment between its delimiters. | ||
8 | * | ||
9 | * @param node the node to fold | ||
10 | * @returns the folding range or `null` is there is nothing to fold | ||
11 | */ | ||
12 | export function foldBlockComment(node: SyntaxNode): FoldRange { | ||
13 | return { | ||
14 | from: node.from + 2, | ||
15 | to: node.to - 2, | ||
16 | }; | ||
17 | } | ||
18 | |||
19 | /** | ||
20 | * Folds a declaration after the first element if it appears on the opening line, | ||
21 | * otherwise folds after the opening keyword. | ||
22 | * | ||
23 | * @example | ||
24 | * First element on the opening line: | ||
25 | * ``` | ||
26 | * scope Family = 1, | ||
27 | * Person += 5..10. | ||
28 | * ``` | ||
29 | * becomes | ||
30 | * ``` | ||
31 | * scope Family = 1,[...]. | ||
32 | * ``` | ||
33 | * | ||
34 | * @example | ||
35 | * First element not on the opening line: | ||
36 | * ``` | ||
37 | * scope Family | ||
38 | * = 1, | ||
39 | * Person += 5..10. | ||
40 | * ``` | ||
41 | * becomes | ||
42 | * ``` | ||
43 | * scope [...]. | ||
44 | * ``` | ||
45 | * | ||
46 | * @param node the node to fold | ||
47 | * @param state the editor state | ||
48 | * @returns the folding range or `null` is there is nothing to fold | ||
49 | */ | ||
50 | export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null { | ||
51 | const { firstChild: open, lastChild: close } = node; | ||
52 | if (open === null || close === null) { | ||
53 | return null; | ||
54 | } | ||
55 | const { cursor } = open; | ||
56 | const lineEnd = state.doc.lineAt(open.from).to; | ||
57 | let foldFrom = open.to; | ||
58 | while (cursor.next() && cursor.from < lineEnd) { | ||
59 | if (cursor.type.name === ',') { | ||
60 | foldFrom = cursor.to; | ||
61 | break; | ||
62 | } | ||
63 | } | ||
64 | return { | ||
65 | from: foldFrom, | ||
66 | to: close.from, | ||
67 | }; | ||
68 | } | ||
69 | |||
70 | /** | ||
71 | * Folds a node only if it has at least one sibling of the same type. | ||
72 | * | ||
73 | * The folding range will be the entire `node`. | ||
74 | * | ||
75 | * @param node the node to fold | ||
76 | * @returns the folding range or `null` is there is nothing to fold | ||
77 | */ | ||
78 | function foldWithSibling(node: SyntaxNode): FoldRange | null { | ||
79 | const { parent } = node; | ||
80 | if (parent === null) { | ||
81 | return null; | ||
82 | } | ||
83 | const { firstChild } = parent; | ||
84 | if (firstChild === null) { | ||
85 | return null; | ||
86 | } | ||
87 | const { cursor } = firstChild; | ||
88 | let nSiblings = 0; | ||
89 | while (cursor.nextSibling()) { | ||
90 | if (cursor.type === node.type) { | ||
91 | nSiblings += 1; | ||
92 | } | ||
93 | if (nSiblings >= 2) { | ||
94 | return { | ||
95 | from: node.from, | ||
96 | to: node.to, | ||
97 | }; | ||
98 | } | ||
99 | } | ||
100 | return null; | ||
101 | } | ||
102 | |||
103 | export function foldWholeNode(node: SyntaxNode): FoldRange { | ||
104 | return { | ||
105 | from: node.from, | ||
106 | to: node.to, | ||
107 | }; | ||
108 | } | ||
109 | |||
110 | export function foldConjunction(node: SyntaxNode): FoldRange | null { | ||
111 | if (node.parent?.type?.name === 'PredicateBody') { | ||
112 | return foldWithSibling(node); | ||
113 | } | ||
114 | return foldWholeNode(node); | ||
115 | } | ||
diff --git a/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 @@ | |||
1 | import { TreeIndentContext } from '@codemirror/language'; | ||
2 | |||
3 | /** | ||
4 | * Finds the `from` of first non-skipped token, if any, | ||
5 | * after the opening keyword in the first line of the declaration. | ||
6 | * | ||
7 | * Based on | ||
8 | * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246 | ||
9 | * | ||
10 | * @param context the indentation context | ||
11 | * @returns the alignment or `null` if there is no token after the opening keyword | ||
12 | */ | ||
13 | function findAlignmentAfterOpening(context: TreeIndentContext): number | null { | ||
14 | const { | ||
15 | node: tree, | ||
16 | simulatedBreak, | ||
17 | } = context; | ||
18 | const openingToken = tree.childAfter(tree.from); | ||
19 | if (openingToken === null) { | ||
20 | return null; | ||
21 | } | ||
22 | const openingLine = context.state.doc.lineAt(openingToken.from); | ||
23 | const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from | ||
24 | ? openingLine.to | ||
25 | : Math.min(openingLine.to, simulatedBreak); | ||
26 | const { cursor } = openingToken; | ||
27 | while (cursor.next() && cursor.from < lineEnd) { | ||
28 | if (!cursor.type.isSkipped) { | ||
29 | return cursor.from; | ||
30 | } | ||
31 | } | ||
32 | return null; | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * Indents text after declarations by a single unit if it begins on a new line, | ||
37 | * otherwise it aligns with the text after the declaration. | ||
38 | * | ||
39 | * Based on | ||
40 | * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275 | ||
41 | * | ||
42 | * @example | ||
43 | * Result with no hanging indent (indent unit = 2 spaces, units = 1): | ||
44 | * ``` | ||
45 | * scope | ||
46 | * Family = 1, | ||
47 | * Person += 5..10. | ||
48 | * ``` | ||
49 | * | ||
50 | * @example | ||
51 | * Result with hanging indent: | ||
52 | * ``` | ||
53 | * scope Family = 1, | ||
54 | * Person += 5..10. | ||
55 | * ``` | ||
56 | * | ||
57 | * @param context the indentation context | ||
58 | * @param units the number of units to indent | ||
59 | * @returns the desired indentation level | ||
60 | */ | ||
61 | function indentDeclarationStrategy(context: TreeIndentContext, units: number): number { | ||
62 | const alignment = findAlignmentAfterOpening(context); | ||
63 | if (alignment !== null) { | ||
64 | return context.column(alignment); | ||
65 | } | ||
66 | return context.baseIndent + units * context.unit; | ||
67 | } | ||
68 | |||
69 | export function indentBlockComment(): number { | ||
70 | // Do not indent. | ||
71 | return -1; | ||
72 | } | ||
73 | |||
74 | export function indentDeclaration(context: TreeIndentContext): number { | ||
75 | return indentDeclarationStrategy(context, 1); | ||
76 | } | ||
77 | |||
78 | export function indentPredicateOrRule(context: TreeIndentContext): number { | ||
79 | const clauseIndent = indentDeclarationStrategy(context, 1); | ||
80 | if (/^\s+(;|\.)/.exec(context.textAfter) !== null) { | ||
81 | return clauseIndent - 2; | ||
82 | } | ||
83 | if (/^\s+(~>)/.exec(context.textAfter) !== null) { | ||
84 | return clauseIndent - 3; | ||
85 | } | ||
86 | return clauseIndent; | ||
87 | } | ||
diff --git a/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 | |||
3 | statement { | ||
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 | |||
41 | ReferenceDeclaration { | ||
42 | (kw<"refers"> | kw<"contains">)? | ||
43 | RelationName | ||
44 | RelationName | ||
45 | ( "[" Multiplicity? "]" )? | ||
46 | (kw<"opposite"> RelationName)? | ||
47 | ";"? | ||
48 | } | ||
49 | |||
50 | Parameter { RelationName? VariableName } | ||
51 | |||
52 | Conjunction { ("," | Literal)+ } | ||
53 | |||
54 | OrOp { ";" } | ||
55 | |||
56 | Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? } | ||
57 | |||
58 | Atom { RelationName "+"? ParameterList<Argument> } | ||
59 | |||
60 | Action { ("," | ActionLiteral)+ } | ||
61 | |||
62 | ActionLiteral { | ||
63 | ckw<"new"> VariableName | | ||
64 | ckw<"delete"> VariableName | | ||
65 | Literal | ||
66 | } | ||
67 | |||
68 | Argument { VariableName | Constant } | ||
69 | |||
70 | AssertionArgument { NodeName | StarArgument | Constant } | ||
71 | |||
72 | Constant { Real | String } | ||
73 | |||
74 | LogicValue { | ||
75 | ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error"> | ||
76 | } | ||
77 | |||
78 | ScopeElement { RelationName ("=" | "+=") Multiplicity } | ||
79 | |||
80 | Multiplicity { (IntMult "..")? (IntMult | StarMult)} | ||
81 | |||
82 | RelationName { QualifiedName } | ||
83 | |||
84 | RuleName { QualifiedName } | ||
85 | |||
86 | UniqueNodeName { QualifiedName } | ||
87 | |||
88 | VariableName { QualifiedName } | ||
89 | |||
90 | NodeName { QualifiedName } | ||
91 | |||
92 | QualifiedName { identifier ("::" identifier)* } | ||
93 | |||
94 | kw<term> { @specialize[@name={term}]<identifier, term> } | ||
95 | |||
96 | ckw<term> { @extend[@name={term}]<identifier, term> } | ||
97 | |||
98 | ParameterList<content> { "(" sep<",", content> ")" } | ||
99 | |||
100 | sep<separator, content> { sep1<separator, content>? } | ||
101 | |||
102 | sep1<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 @@ | |||
1 | import { styleTags, tags as t } from '@codemirror/highlight'; | ||
2 | import { | ||
3 | foldInside, | ||
4 | foldNodeProp, | ||
5 | indentNodeProp, | ||
6 | indentUnit, | ||
7 | LanguageSupport, | ||
8 | LRLanguage, | ||
9 | } from '@codemirror/language'; | ||
10 | import { LRParser } from '@lezer/lr'; | ||
11 | |||
12 | import { parser } from '../../../../build/generated/sources/lezer/problem'; | ||
13 | import { | ||
14 | foldBlockComment, | ||
15 | foldConjunction, | ||
16 | foldDeclaration, | ||
17 | foldWholeNode, | ||
18 | } from './folding'; | ||
19 | import { | ||
20 | indentBlockComment, | ||
21 | indentDeclaration, | ||
22 | indentPredicateOrRule, | ||
23 | } from './indentation'; | ||
24 | |||
25 | const parserWithMetadata = (parser as LRParser).configure({ | ||
26 | props: [ | ||
27 | styleTags({ | ||
28 | LineComment: t.lineComment, | ||
29 | BlockComment: t.blockComment, | ||
30 | 'problem class enum pred rule 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 | |||
74 | const problemLanguage = LRLanguage.define({ | ||
75 | parser: parserWithMetadata, | ||
76 | languageData: { | ||
77 | commentTokens: { | ||
78 | block: { | ||
79 | open: '/*', | ||
80 | close: '*/', | ||
81 | }, | ||
82 | line: '%', | ||
83 | }, | ||
84 | indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.|~>)$/, | ||
85 | }, | ||
86 | }); | ||
87 | |||
88 | export function problemLanguageSupport(): LanguageSupport { | ||
89 | return new LanguageSupport(problemLanguage, [ | ||
90 | indentUnit.of(' '), | ||
91 | ]); | ||
92 | } | ||
diff --git a/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 @@ | |||
1 | import { getLogger } from './logger'; | ||
2 | import { PendingTask } from './PendingTask'; | ||
3 | |||
4 | const log = getLogger('utils.ConditionVariable'); | ||
5 | |||
6 | export type Condition = () => boolean; | ||
7 | |||
8 | export class ConditionVariable { | ||
9 | condition: Condition; | ||
10 | |||
11 | defaultTimeout: number; | ||
12 | |||
13 | listeners: PendingTask<void>[] = []; | ||
14 | |||
15 | constructor(condition: Condition, defaultTimeout = 0) { | ||
16 | this.condition = condition; | ||
17 | this.defaultTimeout = defaultTimeout; | ||
18 | } | ||
19 | |||
20 | async waitFor(timeoutMs: number | null = null): Promise<void> { | ||
21 | if (this.condition()) { | ||
22 | return; | ||
23 | } | ||
24 | const timeoutOrDefault = timeoutMs || this.defaultTimeout; | ||
25 | let nowMs = Date.now(); | ||
26 | const endMs = nowMs + timeoutOrDefault; | ||
27 | while (!this.condition() && nowMs < endMs) { | ||
28 | const remainingMs = endMs - nowMs; | ||
29 | const promise = new Promise<void>((resolve, reject) => { | ||
30 | if (this.condition()) { | ||
31 | resolve(); | ||
32 | return; | ||
33 | } | ||
34 | const task = new PendingTask(resolve, reject, remainingMs); | ||
35 | this.listeners.push(task); | ||
36 | }); | ||
37 | // We must keep waiting until the update has completed, | ||
38 | // so the tasks can't be started in parallel. | ||
39 | // eslint-disable-next-line no-await-in-loop | ||
40 | await promise; | ||
41 | nowMs = Date.now(); | ||
42 | } | ||
43 | if (!this.condition()) { | ||
44 | log.error('Condition still does not hold after', timeoutOrDefault, 'ms'); | ||
45 | throw new Error('Failed to wait for condition'); | ||
46 | } | ||
47 | } | ||
48 | |||
49 | notifyAll(): void { | ||
50 | this.clearListenersWith((listener) => listener.resolve()); | ||
51 | } | ||
52 | |||
53 | rejectAll(error: unknown): void { | ||
54 | this.clearListenersWith((listener) => listener.reject(error)); | ||
55 | } | ||
56 | |||
57 | private clearListenersWith(callback: (listener: PendingTask<void>) => void) { | ||
58 | // Copy `listeners` so that we don't get into a race condition | ||
59 | // if one of the listeners adds another listener. | ||
60 | const { listeners } = this; | ||
61 | this.listeners = []; | ||
62 | listeners.forEach(callback); | ||
63 | } | ||
64 | } | ||
diff --git a/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 @@ | |||
1 | import { getLogger } from './logger'; | ||
2 | |||
3 | const log = getLogger('utils.PendingTask'); | ||
4 | |||
5 | export class PendingTask<T> { | ||
6 | private readonly resolveCallback: (value: T) => void; | ||
7 | |||
8 | private readonly rejectCallback: (reason?: unknown) => void; | ||
9 | |||
10 | private resolved = false; | ||
11 | |||
12 | private timeout: 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 @@ | |||
1 | export 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'; | |||
2 | import log from 'loglevel'; | 2 | import log from 'loglevel'; |
3 | import * as prefix from 'loglevel-plugin-prefix'; | 3 | import * as prefix from 'loglevel-plugin-prefix'; |
4 | 4 | ||
5 | const colors: Record<string, CSPair> = { | 5 | const 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 | |||
10 | define([], 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 @@ | |||
1 | import type { | ||
2 | Completion, | ||
3 | CompletionContext, | ||
4 | CompletionResult, | ||
5 | } from '@codemirror/autocomplete'; | ||
6 | import type { Transaction } from '@codemirror/state'; | ||
7 | import escapeStringRegexp from 'escape-string-regexp'; | ||
8 | |||
9 | import type { UpdateService } from './UpdateService'; | ||
10 | import { getLogger } from '../utils/logger'; | ||
11 | import type { IContentAssistEntry } from './xtextServiceResults'; | ||
12 | |||
13 | const PROPOSALS_LIMIT = 1000; | ||
14 | |||
15 | const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*'; | ||
16 | |||
17 | const HIGH_PRIORITY_KEYWORDS = ['<->']; | ||
18 | |||
19 | const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g; | ||
20 | |||
21 | const log = getLogger('xtext.ContentAssistService'); | ||
22 | |||
23 | function 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 | |||
50 | function 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 | |||
63 | export 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 @@ | |||
1 | import type { EditorStore } from '../editor/EditorStore'; | ||
2 | import type { IHighlightRange } from '../editor/semanticHighlighting'; | ||
3 | import type { UpdateService } from './UpdateService'; | ||
4 | import { getLogger } from '../utils/logger'; | ||
5 | import { isHighlightingResult } from './xtextServiceResults'; | ||
6 | |||
7 | const log = getLogger('xtext.ValidationService'); | ||
8 | |||
9 | export 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 @@ | |||
1 | import { Transaction } from '@codemirror/state'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { IOccurrence } from '../editor/findOccurrences'; | ||
5 | import type { UpdateService } from './UpdateService'; | ||
6 | import { getLogger } from '../utils/logger'; | ||
7 | import { Timer } from '../utils/Timer'; | ||
8 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
9 | import { | ||
10 | isOccurrencesResult, | ||
11 | isServiceConflictResult, | ||
12 | ITextRegion, | ||
13 | } from './xtextServiceResults'; | ||
14 | |||
15 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; | ||
16 | |||
17 | // Must clear occurrences asynchronously from `onTransaction`, | ||
18 | // because we must not emit a conflicting transaction when handling the pending transaction. | ||
19 | const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; | ||
20 | |||
21 | const log = getLogger('xtext.OccurrencesService'); | ||
22 | |||
23 | function transformOccurrences(regions: 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 | |||
36 | export class OccurrencesService { | ||
37 | private readonly store: EditorStore; | ||
38 | |||
39 | private readonly webSocketClient: XtextWebSocketClient; | ||
40 | |||
41 | private readonly updateService: UpdateService; | ||
42 | |||
43 | private hasOccurrences = false; | ||
44 | |||
45 | private readonly findOccurrencesTimer = new Timer(() => { | ||
46 | this.handleFindOccurrences(); | ||
47 | }, FIND_OCCURRENCES_TIMEOUT_MS); | ||
48 | |||
49 | private readonly clearOccurrencesTimer = new Timer(() => { | ||
50 | this.clearOccurrences(); | ||
51 | }, CLEAR_OCCURRENCES_TIMEOUT_MS); | ||
52 | |||
53 | constructor( | ||
54 | store: EditorStore, | ||
55 | webSocketClient: XtextWebSocketClient, | ||
56 | updateService: UpdateService, | ||
57 | ) { | ||
58 | this.store = store; | ||
59 | this.webSocketClient = webSocketClient; | ||
60 | this.updateService = updateService; | ||
61 | } | ||
62 | |||
63 | onTransaction(transaction: Transaction): void { | ||
64 | if (transaction.docChanged) { | ||
65 | this.clearOccurrencesTimer.schedule(); | ||
66 | this.findOccurrencesTimer.reschedule(); | ||
67 | } | ||
68 | if (transaction.isUserEvent('select')) { | ||
69 | this.findOccurrencesTimer.reschedule(); | ||
70 | } | ||
71 | } | ||
72 | |||
73 | private handleFindOccurrences() { | ||
74 | this.clearOccurrencesTimer.cancel(); | ||
75 | this.updateOccurrences().catch((error) => { | ||
76 | log.error('Unexpected error while updating occurrences', error); | ||
77 | this.clearOccurrences(); | ||
78 | }); | ||
79 | } | ||
80 | |||
81 | private async updateOccurrences() { | ||
82 | await this.updateService.update(); | ||
83 | const result = await this.webSocketClient.send({ | ||
84 | resource: this.updateService.resourceName, | ||
85 | serviceType: 'occurrences', | ||
86 | expectedStateId: this.updateService.xtextStateId, | ||
87 | caretOffset: this.store.state.selection.main.head, | ||
88 | }); | ||
89 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | ||
90 | if (!allChanges.empty | ||
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 | |||
10 | define([ | ||
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 @@ | |||
1 | import { | ||
2 | ChangeDesc, | ||
3 | ChangeSet, | ||
4 | Transaction, | ||
5 | } from '@codemirror/state'; | ||
6 | import { nanoid } from 'nanoid'; | ||
7 | |||
8 | import type { EditorStore } from '../editor/EditorStore'; | ||
9 | import type { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
10 | import { ConditionVariable } from '../utils/ConditionVariable'; | ||
11 | import { getLogger } from '../utils/logger'; | ||
12 | import { Timer } from '../utils/Timer'; | ||
13 | import { | ||
14 | IContentAssistEntry, | ||
15 | isContentAssistResult, | ||
16 | isDocumentStateResult, | ||
17 | isInvalidStateIdConflictResult, | ||
18 | } from './xtextServiceResults'; | ||
19 | |||
20 | const UPDATE_TIMEOUT_MS = 500; | ||
21 | |||
22 | const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | ||
23 | |||
24 | const log = getLogger('xtext.UpdateService'); | ||
25 | |||
26 | export interface IAbortSignal { | ||
27 | aborted: boolean; | ||
28 | } | ||
29 | |||
30 | export 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 @@ | |||
1 | import type { Diagnostic } from '@codemirror/lint'; | ||
2 | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | ||
4 | import type { UpdateService } from './UpdateService'; | ||
5 | import { getLogger } from '../utils/logger'; | ||
6 | import { isValidationResult } from './xtextServiceResults'; | ||
7 | |||
8 | const log = getLogger('xtext.ValidationService'); | ||
9 | |||
10 | export 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 @@ | |||
1 | import type { | ||
2 | CompletionContext, | ||
3 | CompletionResult, | ||
4 | } from '@codemirror/autocomplete'; | ||
5 | import type { Transaction } from '@codemirror/state'; | ||
6 | |||
7 | import type { EditorStore } from '../editor/EditorStore'; | ||
8 | import { ContentAssistService } from './ContentAssistService'; | ||
9 | import { HighlightingService } from './HighlightingService'; | ||
10 | import { OccurrencesService } from './OccurrencesService'; | ||
11 | import { UpdateService } from './UpdateService'; | ||
12 | import { getLogger } from '../utils/logger'; | ||
13 | import { ValidationService } from './ValidationService'; | ||
14 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | ||
15 | |||
16 | const log = getLogger('xtext.XtextClient'); | ||
17 | |||
18 | export 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 @@ | |||
1 | import { nanoid } from 'nanoid'; | ||
2 | |||
3 | import { getLogger } from '../utils/logger'; | ||
4 | import { PendingTask } from '../utils/PendingTask'; | ||
5 | import { Timer } from '../utils/Timer'; | ||
6 | import { | ||
7 | isErrorResponse, | ||
8 | isOkResponse, | ||
9 | isPushMessage, | ||
10 | IXtextWebRequest, | ||
11 | } from './xtextMessages'; | ||
12 | import { isPongResult } from './xtextServiceResults'; | ||
13 | |||
14 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | ||
15 | |||
16 | const WEBSOCKET_CLOSE_OK = 1000; | ||
17 | |||
18 | const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; | ||
19 | |||
20 | const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; | ||
21 | |||
22 | const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; | ||
23 | |||
24 | const PING_TIMEOUT_MS = 10 * 1000; | ||
25 | |||
26 | const REQUEST_TIMEOUT_MS = 1000; | ||
27 | |||
28 | const log = getLogger('xtext.XtextWebSocketClient'); | ||
29 | |||
30 | export type ReconnectHandler = () => void; | ||
31 | |||
32 | export type PushHandler = ( | ||
33 | resourceId: string, | ||
34 | stateId: string, | ||
35 | service: string, | ||
36 | data: unknown, | ||
37 | ) => void; | ||
38 | |||
39 | enum State { | ||
40 | Initial, | ||
41 | Opening, | ||
42 | TabVisible, | ||
43 | TabHiddenIdle, | ||
44 | TabHiddenWaiting, | ||
45 | Error, | ||
46 | TimedOut, | ||
47 | } | ||
48 | |||
49 | export 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 | |||
10 | define([], 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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 | |||
10 | define(['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 @@ | |||
1 | import { Editor } from 'codemirror'; | ||
2 | |||
3 | export function createEditor(options: IXtextOptions): IXtextCodeMirrorEditor; | ||
4 | |||
5 | export function createServices(editor: Editor, options: IXtextOptions): IXtextServices; | ||
6 | |||
7 | export function removeServices(editor: Editor): void; | ||
8 | |||
9 | export 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 | |||
38 | export interface IXtextCodeMirrorEditor extends Editor { | ||
39 | xtextServices: IXtextServices; | ||
40 | } | ||
41 | |||
42 | export 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 | */ | ||
76 | define([ | ||
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, '"') + '"' : ''; | ||
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 @@ | |||
1 | export interface IXtextWebRequest { | ||
2 | id: string; | ||
3 | |||
4 | request: unknown; | ||
5 | } | ||
6 | |||
7 | export interface IXtextWebOkResponse { | ||
8 | id: string; | ||
9 | |||
10 | response: unknown; | ||
11 | } | ||
12 | |||
13 | export 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 | |||
20 | export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const; | ||
21 | |||
22 | export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; | ||
23 | |||
24 | export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind { | ||
25 | return typeof value === 'string' | ||
26 | && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind); | ||
27 | } | ||
28 | |||
29 | export interface IXtextWebErrorResponse { | ||
30 | id: string; | ||
31 | |||
32 | error: XtextWebErrorKind; | ||
33 | |||
34 | message: string; | ||
35 | } | ||
36 | |||
37 | export 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 | |||
45 | export interface IXtextWebPushMessage { | ||
46 | resource: string; | ||
47 | |||
48 | stateId: string; | ||
49 | |||
50 | service: string; | ||
51 | |||
52 | push: unknown; | ||
53 | } | ||
54 | |||
55 | export 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 @@ | |||
1 | export interface IPongResult { | ||
2 | pong: string; | ||
3 | } | ||
4 | |||
5 | export 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 | |||
11 | export interface IDocumentStateResult { | ||
12 | stateId: string; | ||
13 | } | ||
14 | |||
15 | export 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 | |||
21 | export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const; | ||
22 | |||
23 | export type Conflict = typeof VALID_CONFLICTS[number]; | ||
24 | |||
25 | export function isConflict(value: unknown): value is Conflict { | ||
26 | return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict); | ||
27 | } | ||
28 | |||
29 | export interface IServiceConflictResult { | ||
30 | conflict: Conflict; | ||
31 | } | ||
32 | |||
33 | export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { | ||
34 | const serviceConflictResult = result as IServiceConflictResult; | ||
35 | return typeof serviceConflictResult === 'object' | ||
36 | && isConflict(serviceConflictResult.conflict); | ||
37 | } | ||
38 | |||
39 | export function isInvalidStateIdConflictResult(result: unknown): boolean { | ||
40 | return isServiceConflictResult(result) && result.conflict === 'invalidStateId'; | ||
41 | } | ||
42 | |||
43 | export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; | ||
44 | |||
45 | export type Severity = typeof VALID_SEVERITIES[number]; | ||
46 | |||
47 | export function isSeverity(value: unknown): value is Severity { | ||
48 | return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity); | ||
49 | } | ||
50 | |||
51 | export 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 | |||
65 | export 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 | |||
76 | export interface IValidationResult { | ||
77 | issues: IIssue[]; | ||
78 | } | ||
79 | |||
80 | function 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 | |||
84 | export 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 | |||
90 | export interface IReplaceRegion { | ||
91 | offset: number; | ||
92 | |||
93 | length: number; | ||
94 | |||
95 | text: string; | ||
96 | } | ||
97 | |||
98 | export 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 | |||
106 | export interface ITextRegion { | ||
107 | offset: number; | ||
108 | |||
109 | length: number; | ||
110 | } | ||
111 | |||
112 | export 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 | |||
119 | export 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 | |||
141 | export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number]; | ||
142 | |||
143 | export 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 | |||
150 | export 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 | |||
170 | function isStringOrUndefined(value: unknown): value is string | undefined { | ||
171 | return typeof value === 'string' || typeof value === 'undefined'; | ||
172 | } | ||
173 | |||
174 | function isNumberOrUndefined(value: unknown): value is number | undefined { | ||
175 | return typeof value === 'number' || typeof value === 'undefined'; | ||
176 | } | ||
177 | |||
178 | export 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 | |||
192 | export interface IContentAssistResult extends IDocumentStateResult { | ||
193 | entries: IContentAssistEntry[]; | ||
194 | } | ||
195 | |||
196 | export function isContentAssistResult(result: unknown): result is IContentAssistResult { | ||
197 | const contentAssistResult = result as IContentAssistResult; | ||
198 | return isDocumentStateResult(result) | ||
199 | && isArrayOfType(contentAssistResult.entries, isContentAssistEntry); | ||
200 | } | ||
201 | |||
202 | export interface IHighlightingRegion { | ||
203 | offset: number; | ||
204 | |||
205 | length: number; | ||
206 | |||
207 | styleClasses: string[]; | ||
208 | } | ||
209 | |||
210 | export 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 | |||
218 | export interface IHighlightingResult { | ||
219 | regions: IHighlightingRegion[]; | ||
220 | } | ||
221 | |||
222 | export 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 | |||
228 | export interface IOccurrencesResult extends IDocumentStateResult { | ||
229 | writeRegions: ITextRegion[]; | ||
230 | |||
231 | readRegions: ITextRegion[]; | ||
232 | } | ||
233 | |||
234 | export 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 | } | ||