aboutsummaryrefslogtreecommitdiffstats
path: root/language-web/src/main/js
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <marussy@mit.bme.hu>2021-10-31 19:41:24 +0100
committerLibravatar GitHub <noreply@github.com>2021-10-31 19:41:24 +0100
commit7918d78948de7f349f9948837caf76f2a514c96c (patch)
tree0f7c24a9e0046dffd719d6a66be4a1f73b7fa4c7 /language-web/src/main/js
parentMerge pull request #7 from golej-marci/language-to-store (diff)
parentchore: bump dependency versions (diff)
downloadrefinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.gz
refinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.zst
refinery-7918d78948de7f349f9948837caf76f2a514c96c.zip
Merge pull request #8 from kris7t/cm6
Switch to CodeMirror 6 editor and WebSocket-based transport for Xtext
Diffstat (limited to 'language-web/src/main/js')
-rw-r--r--language-web/src/main/js/App.tsx11
-rw-r--r--language-web/src/main/js/RootStore.tsx4
-rw-r--r--language-web/src/main/js/editor/EditorArea.tsx170
-rw-r--r--language-web/src/main/js/editor/EditorButtons.tsx70
-rw-r--r--language-web/src/main/js/editor/EditorParent.ts200
-rw-r--r--language-web/src/main/js/editor/EditorStore.ts358
-rw-r--r--language-web/src/main/js/editor/GenerateButton.tsx44
-rw-r--r--language-web/src/main/js/editor/decorationSetExtension.ts39
-rw-r--r--language-web/src/main/js/editor/editor.ts18
-rw-r--r--language-web/src/main/js/editor/findOccurrences.ts35
-rw-r--r--language-web/src/main/js/editor/semanticHighlighting.ts24
-rw-r--r--language-web/src/main/js/index.tsx30
-rw-r--r--language-web/src/main/js/language/folding.ts115
-rw-r--r--language-web/src/main/js/language/indentation.ts87
-rw-r--r--language-web/src/main/js/language/problem.grammar145
-rw-r--r--language-web/src/main/js/language/problemLanguageSupport.ts92
-rw-r--r--language-web/src/main/js/theme/ThemeStore.ts7
-rw-r--r--language-web/src/main/js/utils/ConditionVariable.ts64
-rw-r--r--language-web/src/main/js/utils/PendingTask.ts60
-rw-r--r--language-web/src/main/js/utils/Timer.ts33
-rw-r--r--language-web/src/main/js/utils/logger.ts (renamed from language-web/src/main/js/logging.tsx)2
-rw-r--r--language-web/src/main/js/xtext/CodeMirrorEditorContext.js111
-rw-r--r--language-web/src/main/js/xtext/ContentAssistService.ts177
-rw-r--r--language-web/src/main/js/xtext/HighlightingService.ts43
-rw-r--r--language-web/src/main/js/xtext/OccurrencesService.ts116
-rw-r--r--language-web/src/main/js/xtext/ServiceBuilder.js285
-rw-r--r--language-web/src/main/js/xtext/UpdateService.ts310
-rw-r--r--language-web/src/main/js/xtext/ValidationService.ts45
-rw-r--r--language-web/src/main/js/xtext/XtextClient.ts83
-rw-r--r--language-web/src/main/js/xtext/XtextWebSocketClient.ts341
-rw-r--r--language-web/src/main/js/xtext/compatibility.js63
-rw-r--r--language-web/src/main/js/xtext/services/ContentAssistService.js132
-rw-r--r--language-web/src/main/js/xtext/services/FormattingService.js52
-rw-r--r--language-web/src/main/js/xtext/services/HighlightingService.js33
-rw-r--r--language-web/src/main/js/xtext/services/HoverService.js59
-rw-r--r--language-web/src/main/js/xtext/services/LoadResourceService.js42
-rw-r--r--language-web/src/main/js/xtext/services/OccurrencesService.js39
-rw-r--r--language-web/src/main/js/xtext/services/SaveResourceService.js32
-rw-r--r--language-web/src/main/js/xtext/services/UpdateService.js159
-rw-r--r--language-web/src/main/js/xtext/services/ValidationService.js33
-rw-r--r--language-web/src/main/js/xtext/services/XtextService.js280
-rw-r--r--language-web/src/main/js/xtext/xtext-codemirror.d.ts43
-rw-r--r--language-web/src/main/js/xtext/xtext-codemirror.js473
-rw-r--r--language-web/src/main/js/xtext/xtextMessages.ts62
-rw-r--r--language-web/src/main/js/xtext/xtextServiceResults.ts239
45 files changed, 2802 insertions, 2058 deletions
diff --git a/language-web/src/main/js/App.tsx b/language-web/src/main/js/App.tsx
index d25ac4d3..2567aa9c 100644
--- a/language-web/src/main/js/App.tsx
+++ b/language-web/src/main/js/App.tsx
@@ -1,15 +1,14 @@
1import AppBar from '@mui/material/AppBar'; 1import AppBar from '@mui/material/AppBar';
2import Box from '@mui/material/Box'; 2import Box from '@mui/material/Box';
3import Button from '@mui/material/Button';
4import IconButton from '@mui/material/IconButton'; 3import IconButton from '@mui/material/IconButton';
5import Toolbar from '@mui/material/Toolbar'; 4import Toolbar from '@mui/material/Toolbar';
6import Typography from '@mui/material/Typography'; 5import Typography from '@mui/material/Typography';
7import MenuIcon from '@mui/icons-material/Menu'; 6import MenuIcon from '@mui/icons-material/Menu';
8import PlayArrowIcon from '@mui/icons-material/PlayArrow';
9import React from 'react'; 7import React from 'react';
10 8
11import { EditorArea } from './editor/EditorArea'; 9import { EditorArea } from './editor/EditorArea';
12import { EditorButtons } from './editor/EditorButtons'; 10import { EditorButtons } from './editor/EditorButtons';
11import { GenerateButton } from './editor/GenerateButton';
13 12
14export const App = (): JSX.Element => ( 13export const App = (): JSX.Element => (
15 <Box 14 <Box
@@ -46,13 +45,7 @@ export const App = (): JSX.Element => (
46 p={1} 45 p={1}
47 > 46 >
48 <EditorButtons /> 47 <EditorButtons />
49 <Button 48 <GenerateButton />
50 variant="outlined"
51 color="primary"
52 startIcon={<PlayArrowIcon />}
53 >
54 Generate
55 </Button>
56 </Box> 49 </Box>
57 <Box 50 <Box
58 flexGrow={1} 51 flexGrow={1}
diff --git a/language-web/src/main/js/RootStore.tsx b/language-web/src/main/js/RootStore.tsx
index 88b8a445..96e1b26a 100644
--- a/language-web/src/main/js/RootStore.tsx
+++ b/language-web/src/main/js/RootStore.tsx
@@ -8,9 +8,9 @@ export class RootStore {
8 8
9 themeStore; 9 themeStore;
10 10
11 constructor() { 11 constructor(initialValue: string) {
12 this.themeStore = new ThemeStore(); 12 this.themeStore = new ThemeStore();
13 this.editorStore = new EditorStore(this.themeStore); 13 this.editorStore = new EditorStore(initialValue, this.themeStore);
14 } 14 }
15} 15}
16 16
diff --git a/language-web/src/main/js/editor/EditorArea.tsx b/language-web/src/main/js/editor/EditorArea.tsx
index 531a57c9..678a632d 100644
--- a/language-web/src/main/js/editor/EditorArea.tsx
+++ b/language-web/src/main/js/editor/EditorArea.tsx
@@ -1,41 +1,151 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint';
1import { observer } from 'mobx-react-lite'; 4import { observer } from 'mobx-react-lite';
2import React, { useRef } from 'react'; 5import React, {
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
3 11
12import { EditorParent } from './EditorParent';
4import { useRootStore } from '../RootStore'; 13import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger';
15
16const log = getLogger('editor.EditorArea');
17
18function usePanel(
19 panelId: string,
20 stateToSet: boolean,
21 editorView: EditorView | null,
22 openCommand: Command,
23 closeCommand: Command,
24 closeCallback: () => void,
25) {
26 const [cachedViewState, setCachedViewState] = useState<boolean>(false);
27 useEffect(() => {
28 if (editorView === null || cachedViewState === stateToSet) {
29 return;
30 }
31 if (stateToSet) {
32 openCommand(editorView);
33 const buttonQuery = `.cm-${panelId}.cm-panel button[name="close"]`;
34 const closeButton = editorView.dom.querySelector(buttonQuery);
35 if (closeButton) {
36 log.debug('Addig close button callback to', panelId, 'panel');
37 // We must remove the event listener added by CodeMirror from the button
38 // that dispatches a transaction without going through `EditorStorre`.
39 // Cloning a DOM node removes event listeners,
40 // see https://stackoverflow.com/a/9251864
41 const closeButtonWithoutListeners = closeButton.cloneNode(true);
42 closeButtonWithoutListeners.addEventListener('click', (event) => {
43 closeCallback();
44 event.preventDefault();
45 });
46 closeButton.replaceWith(closeButtonWithoutListeners);
47 } else {
48 log.error('Opened', panelId, 'panel has no close button');
49 }
50 } else {
51 closeCommand(editorView);
52 }
53 setCachedViewState(stateToSet);
54 }, [
55 stateToSet,
56 editorView,
57 cachedViewState,
58 panelId,
59 openCommand,
60 closeCommand,
61 closeCallback,
62 ]);
63 return setCachedViewState;
64}
65
66function fixCodeMirrorAccessibility(editorView: EditorView) {
67 // Reported by Lighthouse 8.3.0.
68 const { contentDOM } = editorView;
69 contentDOM.removeAttribute('aria-expanded');
70 contentDOM.setAttribute('aria-label', 'Code editor');
71}
5 72
6export const EditorArea = observer(() => { 73export const EditorArea = observer(() => {
7 const { editorStore } = useRootStore(); 74 const { editorStore } = useRootStore();
8 const { CodeMirror } = editorStore.chunk || {}; 75 const editorParentRef = useRef<HTMLDivElement | null>(null);
9 const fallbackTextarea = useRef<HTMLTextAreaElement>(null); 76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null);
10 77
11 if (!CodeMirror) { 78 const setSearchPanelOpen = usePanel(
12 return ( 79 'search',
13 <textarea 80 editorStore.showSearchPanel,
14 value={editorStore.value} 81 editorViewState,
15 onChange={(e) => editorStore.updateValue(e.target.value)} 82 openSearchPanel,
16 ref={fallbackTextarea} 83 closeSearchPanel,
17 className={`problem-fallback-editor cm-s-${editorStore.codeMirrorTheme}`} 84 useCallback(() => editorStore.setSearchPanelOpen(false), [editorStore]),
18 /> 85 );
19 ); 86
20 } 87 const setLintPanelOpen = usePanel(
21 88 'panel-lint',
22 const textarea = fallbackTextarea.current; 89 editorStore.showLintPanel,
23 if (textarea) { 90 editorViewState,
24 editorStore.setInitialSelection( 91 openLintPanel,
25 textarea.selectionStart, 92 closeLintPanel,
26 textarea.selectionEnd, 93 useCallback(() => editorStore.setLintPanelOpen(false), [editorStore]),
27 document.activeElement === textarea, 94 );
28 ); 95
29 } 96 useEffect(() => {
97 if (editorParentRef.current === null) {
98 // Nothing to clean up.
99 return () => {};
100 }
101
102 const editorView = new EditorView({
103 state: editorStore.state,
104 parent: editorParentRef.current,
105 dispatch: (transaction) => {
106 editorStore.onTransaction(transaction);
107 editorView.update([transaction]);
108 if (editorView.state !== editorStore.state) {
109 log.error(
110 'Failed to synchronize editor state - store state:',
111 editorStore.state,
112 'view state:',
113 editorView.state,
114 );
115 }
116 },
117 });
118 fixCodeMirrorAccessibility(editorView);
119 setEditorViewState(editorView);
120 setSearchPanelOpen(false);
121 setLintPanelOpen(false);
122 // `dispatch` is bound to the view instance,
123 // so it does not have to be called as a method.
124 // eslint-disable-next-line @typescript-eslint/unbound-method
125 editorStore.updateDispatcher(editorView.dispatch);
126 log.info('Editor created');
127
128 return () => {
129 editorStore.updateDispatcher(null);
130 editorView.destroy();
131 log.info('Editor destroyed');
132 };
133 }, [
134 editorParentRef,
135 editorStore,
136 setSearchPanelOpen,
137 setLintPanelOpen,
138 ]);
30 139
31 return ( 140 return (
32 <CodeMirror 141 <EditorParent
33 value={editorStore.value} 142 className="dark"
34 options={editorStore.codeMirrorOptions} 143 sx={{
35 editorDidMount={(editor) => editorStore.editorDidMount(editor)} 144 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : {
36 editorWillUnmount={() => editorStore.editorWillUnmount()} 145 display: 'none !important',
37 onBeforeChange={(_editor, _data, value) => editorStore.updateValue(value)} 146 },
38 onChange={() => editorStore.reportChanged()} 147 }}
148 ref={editorParentRef}
39 /> 149 />
40 ); 150 );
41}); 151});
diff --git a/language-web/src/main/js/editor/EditorButtons.tsx b/language-web/src/main/js/editor/EditorButtons.tsx
index 56577e82..09ce33dd 100644
--- a/language-web/src/main/js/editor/EditorButtons.tsx
+++ b/language-web/src/main/js/editor/EditorButtons.tsx
@@ -1,14 +1,36 @@
1import type { Diagnostic } from '@codemirror/lint';
1import { observer } from 'mobx-react-lite'; 2import { observer } from 'mobx-react-lite';
3import IconButton from '@mui/material/IconButton';
2import Stack from '@mui/material/Stack'; 4import Stack from '@mui/material/Stack';
3import ToggleButton from '@mui/material/ToggleButton'; 5import ToggleButton from '@mui/material/ToggleButton';
4import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; 6import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
7import CheckIcon from '@mui/icons-material/Check';
8import ErrorIcon from '@mui/icons-material/Error';
5import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; 9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
10import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
6import RedoIcon from '@mui/icons-material/Redo'; 11import RedoIcon from '@mui/icons-material/Redo';
12import SearchIcon from '@mui/icons-material/Search';
7import UndoIcon from '@mui/icons-material/Undo'; 13import UndoIcon from '@mui/icons-material/Undo';
14import WarningIcon from '@mui/icons-material/Warning';
8import React from 'react'; 15import React from 'react';
9 16
10import { useRootStore } from '../RootStore'; 17import { useRootStore } from '../RootStore';
11 18
19// Exhastive switch as proven by TypeScript.
20// eslint-disable-next-line consistent-return
21function getLintIcon(severity: Diagnostic['severity'] | null) {
22 switch (severity) {
23 case 'error':
24 return <ErrorIcon fontSize="small" />;
25 case 'warning':
26 return <WarningIcon fontSize="small" />;
27 case 'info':
28 return <InfoOutlinedIcon fontSize="small" />;
29 case null:
30 return <CheckIcon fontSize="small" />;
31 }
32}
33
12export const EditorButtons = observer(() => { 34export const EditorButtons = observer(() => {
13 const { editorStore } = useRootStore(); 35 const { editorStore } = useRootStore();
14 36
@@ -17,35 +39,55 @@ export const EditorButtons = observer(() => {
17 direction="row" 39 direction="row"
18 spacing={1} 40 spacing={1}
19 > 41 >
20 <ToggleButtonGroup 42 <Stack
21 size="small" 43 direction="row"
44 alignItems="center"
22 > 45 >
23 <ToggleButton 46 <IconButton
24 disabled={!editorStore.canUndo} 47 disabled={!editorStore.canUndo}
25 onClick={() => editorStore.undo()} 48 onClick={() => editorStore.undo()}
26 aria-label="Undo" 49 aria-label="Undo"
27 value="undo" 50 value="undo"
28 > 51 >
29 <UndoIcon fontSize="small" /> 52 <UndoIcon fontSize="small" />
30 </ToggleButton> 53 </IconButton>
31 <ToggleButton 54 <IconButton
32 disabled={!editorStore.canRedo} 55 disabled={!editorStore.canRedo}
33 onClick={() => editorStore.redo()} 56 onClick={() => editorStore.redo()}
34 aria-label="Redo" 57 aria-label="Redo"
35 value="redo" 58 value="redo"
36 > 59 >
37 <RedoIcon fontSize="small" /> 60 <RedoIcon fontSize="small" />
38 </ToggleButton> 61 </IconButton>
39 </ToggleButtonGroup> 62 </Stack>
40 <ToggleButton 63 <ToggleButtonGroup
41 selected={editorStore.showLineNumbers}
42 onChange={() => editorStore.toggleLineNumbers()}
43 size="small" 64 size="small"
44 aria-label="Show line numbers"
45 value="show-line-numbers"
46 > 65 >
47 <FormatListNumberedIcon fontSize="small" /> 66 <ToggleButton
48 </ToggleButton> 67 selected={editorStore.showLineNumbers}
68 onClick={() => editorStore.toggleLineNumbers()}
69 aria-label="Show line numbers"
70 value="show-line-numbers"
71 >
72 <FormatListNumberedIcon fontSize="small" />
73 </ToggleButton>
74 <ToggleButton
75 selected={editorStore.showSearchPanel}
76 onClick={() => editorStore.toggleSearchPanel()}
77 aria-label="Show find/replace"
78 value="show-search-panel"
79 >
80 <SearchIcon fontSize="small" />
81 </ToggleButton>
82 <ToggleButton
83 selected={editorStore.showLintPanel}
84 onClick={() => editorStore.toggleLintPanel()}
85 aria-label="Show diagnostics panel"
86 value="show-lint-panel"
87 >
88 {getLintIcon(editorStore.highestDiagnosticLevel)}
89 </ToggleButton>
90 </ToggleButtonGroup>
49 </Stack> 91 </Stack>
50 ); 92 );
51}); 93});
diff --git a/language-web/src/main/js/editor/EditorParent.ts b/language-web/src/main/js/editor/EditorParent.ts
new file mode 100644
index 00000000..ee1323f6
--- /dev/null
+++ b/language-web/src/main/js/editor/EditorParent.ts
@@ -0,0 +1,200 @@
1import { styled } from '@mui/material/styles';
2
3/**
4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64.
5 *
6 * Based on
7 * https://github.com/codemirror/lint/blob/f524b4a53b0183bb343ac1e32b228d28030d17af/src/lint.ts#L501
8 *
9 * @param color the color of the underline
10 * @returns the CSS `url()`
11 */
12function underline(color: string) {
13 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">
14 <path d="m0 3 l2 -2 l1 0 l2 2 l1 0" stroke="${color}" fill="none" stroke-width=".7"/>
15 </svg>`;
16 const svgBase64 = window.btoa(svg);
17 return `url('data:image/svg+xml;base64,${svgBase64}')`;
18}
19
20export const EditorParent = styled('div')(({ theme }) => {
21 const codeMirrorLintStyle: Record<string, unknown> = {};
22 (['error', 'warning', 'info'] as const).forEach((severity) => {
23 const color = theme.palette[severity].main;
24 codeMirrorLintStyle[`.cm-diagnostic-${severity}`] = {
25 borderLeftColor: color,
26 };
27 codeMirrorLintStyle[`.cm-lintRange-${severity}`] = {
28 backgroundImage: underline(color),
29 };
30 });
31
32 return {
33 background: theme.palette.background.default,
34 '&, .cm-editor': {
35 height: '100%',
36 },
37 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': {
38 fontSize: 16,
39 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
40 fontFeatureSettings: '"liga", "calt"',
41 fontWeight: 400,
42 letterSpacing: 0,
43 textRendering: 'optimizeLegibility',
44 },
45 '.cm-scroller': {
46 color: theme.palette.text.secondary,
47 },
48 '.cm-gutters': {
49 background: theme.palette.background.default,
50 color: theme.palette.text.disabled,
51 border: 'none',
52 },
53 '.cm-specialChar': {
54 color: theme.palette.secondary.main,
55 },
56 '.cm-activeLine': {
57 background: 'rgba(0, 0, 0, 0.3)',
58 },
59 '.cm-activeLineGutter': {
60 background: 'rgba(0, 0, 0, 0.3)',
61 color: theme.palette.text.primary,
62 },
63 '.cm-cursor, .cm-cursor-primary': {
64 borderColor: theme.palette.primary.main,
65 background: theme.palette.common.black,
66 },
67 '.cm-selectionBackground': {
68 background: '#3e4453',
69 },
70 '.cm-focused': {
71 outline: 'none',
72 '.cm-selectionBackground': {
73 background: '#3e4453',
74 },
75 },
76 '.cm-panels-top': {
77 color: theme.palette.text.secondary,
78 },
79 '.cm-panel': {
80 '&, & button, & input': {
81 fontFamily: '"Roboto","Helvetica","Arial",sans-serif',
82 },
83 background: theme.palette.background.paper,
84 borderTop: `1px solid ${theme.palette.divider}`,
85 'button[name="close"]': {
86 background: 'transparent',
87 color: theme.palette.text.secondary,
88 cursor: 'pointer',
89 },
90 },
91 '.cm-panel.cm-panel-lint': {
92 'button[name="close"]': {
93 // Close button interferes with scrollbar, so we better hide it.
94 // The panel can still be closed from the toolbar.
95 display: 'none',
96 },
97 ul: {
98 li: {
99 borderBottom: `1px solid ${theme.palette.divider}`,
100 cursor: 'pointer',
101 },
102 '[aria-selected]': {
103 background: '#3e4453',
104 color: theme.palette.text.primary,
105 },
106 '&:focus [aria-selected]': {
107 background: theme.palette.primary.main,
108 color: theme.palette.primary.contrastText,
109 },
110 },
111 },
112 '.cm-foldPlaceholder': {
113 background: theme.palette.background.paper,
114 borderColor: theme.palette.text.disabled,
115 color: theme.palette.text.secondary,
116 },
117 '.cmt-comment': {
118 fontStyle: 'italic',
119 color: theme.palette.text.disabled,
120 },
121 '.cmt-number': {
122 color: '#6188a6',
123 },
124 '.cmt-string': {
125 color: theme.palette.secondary.dark,
126 },
127 '.cmt-keyword': {
128 color: theme.palette.primary.main,
129 },
130 '.cmt-typeName, .cmt-macroName, .cmt-atom': {
131 color: theme.palette.text.primary,
132 },
133 '.cmt-variableName': {
134 color: '#c8ae9d',
135 },
136 '.cmt-problem-node': {
137 '&, & .cmt-variableName': {
138 color: theme.palette.text.secondary,
139 },
140 },
141 '.cmt-problem-unique': {
142 '&, & .cmt-variableName': {
143 color: theme.palette.text.primary,
144 },
145 },
146 '.cmt-problem-abstract, .cmt-problem-new': {
147 fontStyle: 'italic',
148 },
149 '.cmt-problem-containment': {
150 fontWeight: 700,
151 },
152 '.cmt-problem-error': {
153 '&, & .cmt-typeName': {
154 color: theme.palette.error.main,
155 },
156 },
157 '.cmt-problem-builtin': {
158 '&, & .cmt-typeName, & .cmt-atom, & .cmt-variableName': {
159 color: theme.palette.primary.main,
160 fontWeight: 400,
161 fontStyle: 'normal',
162 },
163 },
164 '.cm-tooltip-autocomplete': {
165 background: theme.palette.background.paper,
166 boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%),
167 0px 4px 5px 0px rgb(0 0 0 / 14%),
168 0px 1px 10px 0px rgb(0 0 0 / 12%)`,
169 '.cm-completionIcon': {
170 color: theme.palette.text.secondary,
171 },
172 '.cm-completionLabel': {
173 color: theme.palette.text.primary,
174 },
175 '.cm-completionDetail': {
176 color: theme.palette.text.secondary,
177 fontStyle: 'normal',
178 },
179 '[aria-selected]': {
180 background: `${theme.palette.primary.main} !important`,
181 '.cm-completionIcon, .cm-completionLabel, .cm-completionDetail': {
182 color: theme.palette.primary.contrastText,
183 },
184 },
185 },
186 '.cm-completionIcon': {
187 width: 16,
188 padding: 0,
189 marginRight: '0.5em',
190 textAlign: 'center',
191 },
192 ...codeMirrorLintStyle,
193 '.cm-problem-write': {
194 background: 'rgba(255, 255, 128, 0.3)',
195 },
196 '.cm-problem-read': {
197 background: 'rgba(255, 255, 255, 0.15)',
198 },
199 };
200});
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts
index 705020b9..ba31efcb 100644
--- a/language-web/src/main/js/editor/EditorStore.ts
+++ b/language-web/src/main/js/editor/EditorStore.ts
@@ -1,201 +1,283 @@
1import type { Editor, EditorConfiguration } from 'codemirror'; 1import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
2import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
3import { defaultKeymap, indentWithTab } from '@codemirror/commands';
4import { commentKeymap } from '@codemirror/comment';
5import { foldGutter, foldKeymap } from '@codemirror/fold';
6import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
7import { classHighlightStyle } from '@codemirror/highlight';
8import {
9 history,
10 historyKeymap,
11 redo,
12 redoDepth,
13 undo,
14 undoDepth,
15} from '@codemirror/history';
16import { indentOnInput } from '@codemirror/language';
17import {
18 Diagnostic,
19 lintKeymap,
20 setDiagnostics,
21} from '@codemirror/lint';
22import { bracketMatching } from '@codemirror/matchbrackets';
23import { rectangularSelection } from '@codemirror/rectangular-selection';
24import { searchConfig, searchKeymap } from '@codemirror/search';
25import {
26 EditorState,
27 StateCommand,
28 StateEffect,
29 Transaction,
30 TransactionSpec,
31} from '@codemirror/state';
32import {
33 drawSelection,
34 EditorView,
35 highlightActiveLine,
36 highlightSpecialChars,
37 keymap,
38} from '@codemirror/view';
2import { 39import {
3 createAtom,
4 makeAutoObservable, 40 makeAutoObservable,
5 observable, 41 observable,
6 runInAction, 42 reaction,
7} from 'mobx'; 43} from 'mobx';
8import type { IXtextOptions, IXtextServices } from 'xtext/xtext-codemirror';
9 44
10import type { IEditorChunk } from './editor'; 45import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences';
11import { getLogger } from '../logging'; 46import { problemLanguageSupport } from '../language/problemLanguageSupport';
47import {
48 IHighlightRange,
49 semanticHighlighting,
50 setSemanticHighlighting,
51} from './semanticHighlighting';
12import type { ThemeStore } from '../theme/ThemeStore'; 52import type { ThemeStore } from '../theme/ThemeStore';
53import { getLogger } from '../utils/logger';
54import { XtextClient } from '../xtext/XtextClient';
13 55
14const log = getLogger('EditorStore'); 56const log = getLogger('editor.EditorStore');
15 57
16const xtextLang = 'problem'; 58export class EditorStore {
59 private readonly themeStore;
17 60
18const xtextOptions: IXtextOptions = { 61 state: EditorState;
19 xtextLang,
20 enableFormattingAction: true,
21};
22 62
23const codeMirrorGlobalOptions: EditorConfiguration = { 63 private readonly client: XtextClient;
24 mode: `xtext/${xtextLang}`,
25 indentUnit: 2,
26 styleActiveLine: true,
27 screenReaderLabel: 'Model source code',
28 inputStyle: 'contenteditable',
29};
30 64
31export class EditorStore { 65 showLineNumbers = false;
32 themeStore;
33 66
34 atom; 67 showSearchPanel = false;
35 68
36 chunk?: IEditorChunk; 69 showLintPanel = false;
37 70
38 editor?: Editor; 71 errorCount = 0;
39 72
40 xtextServices?: IXtextServices; 73 warningCount = 0;
41 74
42 value = ''; 75 infoCount = 0;
43 76
44 showLineNumbers = false; 77 private readonly defaultDispatcher = (tr: Transaction): void => {
78 this.onTransaction(tr);
79 };
45 80
46 initialSelection!: { start: number, end: number, focused: boolean }; 81 private dispatcher = this.defaultDispatcher;
47 82
48 constructor(themeStore: ThemeStore) { 83 constructor(initialValue: string, themeStore: ThemeStore) {
49 this.themeStore = themeStore; 84 this.themeStore = themeStore;
50 this.atom = createAtom('EditorStore'); 85 this.state = EditorState.create({
51 this.resetInitialSelection(); 86 doc: initialValue,
52 makeAutoObservable(this, { 87 extensions: [
53 themeStore: false, 88 autocompletion({
54 atom: false, 89 activateOnTyping: true,
55 chunk: observable.ref, 90 override: [
56 editor: observable.ref, 91 (context) => this.client.contentAssist(context),
57 xtextServices: observable.ref, 92 ],
58 initialSelection: false, 93 }),
94 classHighlightStyle.extension,
95 closeBrackets(),
96 bracketMatching(),
97 drawSelection(),
98 EditorState.allowMultipleSelections.of(true),
99 EditorView.theme({}, {
100 dark: this.themeStore.darkMode,
101 }),
102 findOccurrences,
103 highlightActiveLine(),
104 highlightActiveLineGutter(),
105 highlightSpecialChars(),
106 history(),
107 indentOnInput(),
108 rectangularSelection(),
109 searchConfig({
110 top: true,
111 matchCase: true,
112 }),
113 semanticHighlighting,
114 // We add the gutters to `extensions` in the order we want them to appear.
115 foldGutter(),
116 lineNumbers(),
117 keymap.of([
118 ...closeBracketsKeymap,
119 ...commentKeymap,
120 ...completionKeymap,
121 ...foldKeymap,
122 ...historyKeymap,
123 indentWithTab,
124 // Override keys in `lintKeymap` to go through the `EditorStore`.
125 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
126 ...lintKeymap,
127 // Override keys in `searchKeymap` to go through the `EditorStore`.
128 { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' },
129 { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' },
130 ...searchKeymap,
131 ...defaultKeymap,
132 ]),
133 problemLanguageSupport(),
134 ],
59 }); 135 });
60 this.loadChunk(); 136 this.client = new XtextClient(this);
61 } 137 reaction(
62 138 () => this.themeStore.darkMode,
63 private loadChunk(): void { 139 (darkMode) => {
64 const loadingStartMillis = Date.now(); 140 log.debug('Update editor dark mode', darkMode);
65 log.info('Requesting editor chunk'); 141 this.dispatch({
66 import('./editor').then(({ editorChunk }) => { 142 effects: [
67 runInAction(() => { 143 StateEffect.appendConfig.of(EditorView.theme({}, {
68 this.chunk = editorChunk; 144 dark: darkMode,
69 }); 145 })),
70 const loadingDurationMillis = Date.now() - loadingStartMillis; 146 ],
71 log.info('Loaded editor chunk in', loadingDurationMillis, 'ms'); 147 });
72 }).catch((error) => { 148 },
73 log.error('Error while loading editor', error); 149 );
150 makeAutoObservable(this, {
151 state: observable.ref,
74 }); 152 });
75 } 153 }
76 154
77 setInitialSelection(start: number, end: number, focused: boolean): void { 155 updateDispatcher(newDispatcher: ((tr: Transaction) => void) | null): void {
78 this.initialSelection = { start, end, focused }; 156 this.dispatcher = newDispatcher || this.defaultDispatcher;
79 this.applyInitialSelectionToEditor(); 157 }
158
159 onTransaction(tr: Transaction): void {
160 log.trace('Editor transaction', tr);
161 this.state = tr.state;
162 this.client.onTransaction(tr);
80 } 163 }
81 164
82 private resetInitialSelection(): void { 165 dispatch(...specs: readonly TransactionSpec[]): void {
83 this.initialSelection = { 166 this.dispatcher(this.state.update(...specs));
84 start: 0,
85 end: 0,
86 focused: false,
87 };
88 } 167 }
89 168
90 private applyInitialSelectionToEditor(): void { 169 doStateCommand(command: StateCommand): boolean {
91 if (this.editor) { 170 return command({
92 const { start, end, focused } = this.initialSelection; 171 state: this.state,
93 const doc = this.editor.getDoc(); 172 dispatch: this.dispatcher,
94 const startPos = doc.posFromIndex(start); 173 });
95 const endPos = doc.posFromIndex(end); 174 }
96 doc.setSelection(startPos, endPos, { 175
97 scroll: true, 176 updateDiagnostics(diagnostics: Diagnostic[]): void {
98 }); 177 this.dispatch(setDiagnostics(this.state, diagnostics));
99 if (focused) { 178 this.errorCount = 0;
100 this.editor.focus(); 179 this.warningCount = 0;
180 this.infoCount = 0;
181 diagnostics.forEach(({ severity }) => {
182 switch (severity) {
183 case 'error':
184 this.errorCount += 1;
185 break;
186 case 'warning':
187 this.warningCount += 1;
188 break;
189 case 'info':
190 this.infoCount += 1;
191 break;
101 } 192 }
102 this.resetInitialSelection(); 193 });
103 }
104 } 194 }
105 195
106 /** 196 get highestDiagnosticLevel(): Diagnostic['severity'] | null {
107 * Attaches a new CodeMirror instance and creates Xtext services. 197 if (this.errorCount > 0) {
108 * 198 return 'error';
109 * The store will not subscribe to any CodeMirror events. Instead, 199 }
110 * the editor component should subscribe to them and relay them to the store. 200 if (this.warningCount > 0) {
111 * 201 return 'warning';
112 * @param newEditor The new CodeMirror instance
113 */
114 editorDidMount(newEditor: Editor): void {
115 if (!this.chunk) {
116 throw new Error('Editor not loaded yet');
117 } 202 }
118 if (this.editor) { 203 if (this.infoCount > 0) {
119 throw new Error('CoreMirror editor mounted before unmounting'); 204 return 'info';
120 } 205 }
121 this.editor = newEditor; 206 return null;
122 this.xtextServices = this.chunk.createServices(newEditor, xtextOptions);
123 this.applyInitialSelectionToEditor();
124 } 207 }
125 208
126 editorWillUnmount(): void { 209 updateSemanticHighlighting(ranges: IHighlightRange[]): void {
127 if (!this.chunk) { 210 this.dispatch(setSemanticHighlighting(ranges));
128 throw new Error('Editor not loaded yet'); 211 }
129 } 212
130 if (this.editor) { 213 updateOccurrences(write: IOccurrence[], read: IOccurrence[]): void {
131 this.chunk.removeServices(this.editor); 214 this.dispatch(setOccurrences(write, read));
132 }
133 delete this.editor;
134 delete this.xtextServices;
135 } 215 }
136 216
137 /** 217 /**
138 * Updates the contents of the editor. 218 * @returns `true` if there is history to undo
139 *
140 * @param newValue The new contents of the editor
141 */ 219 */
142 updateValue(newValue: string): void { 220 get canUndo(): boolean {
143 this.value = newValue; 221 return undoDepth(this.state) > 0;
144 } 222 }
145 223
146 reportChanged(): void { 224 // eslint-disable-next-line class-methods-use-this
147 this.atom.reportChanged(); 225 undo(): void {
226 log.debug('Undo', this.doStateCommand(undo));
148 } 227 }
149 228
150 protected observeEditorChanges(): void { 229 /**
151 this.atom.reportObserved(); 230 * @returns `true` if there is history to redo
231 */
232 get canRedo(): boolean {
233 return redoDepth(this.state) > 0;
152 } 234 }
153 235
154 get codeMirrorTheme(): string { 236 // eslint-disable-next-line class-methods-use-this
155 return `problem-${this.themeStore.className}`; 237 redo(): void {
238 log.debug('Redo', this.doStateCommand(redo));
156 } 239 }
157 240
158 get codeMirrorOptions(): EditorConfiguration { 241 toggleLineNumbers(): void {
159 return { 242 this.showLineNumbers = !this.showLineNumbers;
160 ...codeMirrorGlobalOptions, 243 log.debug('Show line numbers', this.showLineNumbers);
161 theme: this.codeMirrorTheme,
162 lineNumbers: this.showLineNumbers,
163 };
164 } 244 }
165 245
166 /** 246 /**
167 * @returns `true` if there is history to undo 247 * Sets whether the CodeMirror search panel should be open.
248 *
249 * This method can be used as a CodeMirror command,
250 * because it returns `false` if it didn't execute,
251 * allowing other commands for the same keybind to run instead.
252 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
253 * commands from `'@codemirror/search'`.
254 *
255 * @param newShosSearchPanel whether we should show the search panel
256 * @returns `true` if the state was changed, `false` otherwise
168 */ 257 */
169 get canUndo(): boolean { 258 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
170 this.observeEditorChanges(); 259 if (this.showSearchPanel === newShowSearchPanel) {
171 if (!this.editor) {
172 return false; 260 return false;
173 } 261 }
174 const { undo: undoSize } = this.editor.historySize(); 262 this.showSearchPanel = newShowSearchPanel;
175 return undoSize > 0; 263 log.debug('Show search panel', this.showSearchPanel);
264 return true;
176 } 265 }
177 266
178 undo(): void { 267 toggleSearchPanel(): void {
179 this.editor?.undo(); 268 this.setSearchPanelOpen(!this.showSearchPanel);
180 } 269 }
181 270
182 /** 271 setLintPanelOpen(newShowLintPanel: boolean): boolean {
183 * @returns `true` if there is history to redo 272 if (this.showLintPanel === newShowLintPanel) {
184 */
185 get canRedo(): boolean {
186 this.observeEditorChanges();
187 if (!this.editor) {
188 return false; 273 return false;
189 } 274 }
190 const { redo: redoSize } = this.editor.historySize(); 275 this.showLintPanel = newShowLintPanel;
191 return redoSize > 0; 276 log.debug('Show lint panel', this.showLintPanel);
192 } 277 return true;
193
194 redo(): void {
195 this.editor?.redo();
196 } 278 }
197 279
198 toggleLineNumbers(): void { 280 toggleLintPanel(): void {
199 this.showLineNumbers = !this.showLineNumbers; 281 this.setLintPanelOpen(!this.showLintPanel);
200 } 282 }
201} 283}
diff --git a/language-web/src/main/js/editor/GenerateButton.tsx b/language-web/src/main/js/editor/GenerateButton.tsx
new file mode 100644
index 00000000..3834cec4
--- /dev/null
+++ b/language-web/src/main/js/editor/GenerateButton.tsx
@@ -0,0 +1,44 @@
1import { observer } from 'mobx-react-lite';
2import Button from '@mui/material/Button';
3import PlayArrowIcon from '@mui/icons-material/PlayArrow';
4import React from 'react';
5
6import { useRootStore } from '../RootStore';
7
8const GENERATE_LABEL = 'Generate';
9
10export const GenerateButton = observer(() => {
11 const { editorStore } = useRootStore();
12 const { errorCount, warningCount } = editorStore;
13
14 const diagnostics: string[] = [];
15 if (errorCount > 0) {
16 diagnostics.push(`${errorCount} error${errorCount === 1 ? '' : 's'}`);
17 }
18 if (warningCount > 0) {
19 diagnostics.push(`${warningCount} warning${warningCount === 1 ? '' : 's'}`);
20 }
21 const summary = diagnostics.join(' and ');
22
23 if (errorCount > 0) {
24 return (
25 <Button
26 variant="outlined"
27 color="error"
28 onClick={() => editorStore.toggleLintPanel()}
29 >
30 {summary}
31 </Button>
32 );
33 }
34
35 return (
36 <Button
37 variant="outlined"
38 color={warningCount > 0 ? 'warning' : 'primary'}
39 startIcon={<PlayArrowIcon />}
40 >
41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
42 </Button>
43 );
44});
diff --git a/language-web/src/main/js/editor/decorationSetExtension.ts b/language-web/src/main/js/editor/decorationSetExtension.ts
new file mode 100644
index 00000000..2d630c20
--- /dev/null
+++ b/language-web/src/main/js/editor/decorationSetExtension.ts
@@ -0,0 +1,39 @@
1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state';
2import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
3
4export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec;
5
6export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] {
7 const setEffect = StateEffect.define<DecorationSet>();
8 const field = StateField.define<DecorationSet>({
9 create() {
10 return Decoration.none;
11 },
12 update(currentDecorations, transaction) {
13 let newDecorations: DecorationSet | null = null;
14 transaction.effects.forEach((effect) => {
15 if (effect.is(setEffect)) {
16 newDecorations = effect.value;
17 }
18 });
19 if (newDecorations === null) {
20 if (transaction.docChanged) {
21 return currentDecorations.map(transaction.changes);
22 }
23 return currentDecorations;
24 }
25 return newDecorations;
26 },
27 provide: (f) => EditorView.decorations.from(f),
28 });
29
30 function transactionSpecFactory(decorations: DecorationSet) {
31 return {
32 effects: [
33 setEffect.of(decorations),
34 ],
35 };
36 }
37
38 return [transactionSpecFactory, field];
39}
diff --git a/language-web/src/main/js/editor/editor.ts b/language-web/src/main/js/editor/editor.ts
deleted file mode 100644
index fbf8796b..00000000
--- a/language-web/src/main/js/editor/editor.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import 'codemirror/addon/selection/active-line';
2import 'mode-problem';
3import { Controlled } from 'react-codemirror2';
4import { createServices, removeServices } from 'xtext/xtext-codemirror';
5
6export interface IEditorChunk {
7 CodeMirror: typeof Controlled;
8
9 createServices: typeof createServices;
10
11 removeServices: typeof removeServices;
12}
13
14export const editorChunk: IEditorChunk = {
15 CodeMirror: Controlled,
16 createServices,
17 removeServices,
18};
diff --git a/language-web/src/main/js/editor/findOccurrences.ts b/language-web/src/main/js/editor/findOccurrences.ts
new file mode 100644
index 00000000..92102746
--- /dev/null
+++ b/language-web/src/main/js/editor/findOccurrences.ts
@@ -0,0 +1,35 @@
1import { Range, RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IOccurrence {
8 from: number;
9
10 to: number;
11}
12
13const [setOccurrencesInteral, findOccurrences] = decorationSetExtension();
14
15const writeDecoration = Decoration.mark({
16 class: 'cm-problem-write',
17});
18
19const readDecoration = Decoration.mark({
20 class: 'cm-problem-read',
21});
22
23export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec {
24 const decorations: Range<Decoration>[] = [];
25 write.forEach(({ from, to }) => {
26 decorations.push(writeDecoration.range(from, to));
27 });
28 read.forEach(({ from, to }) => {
29 decorations.push(readDecoration.range(from, to));
30 });
31 const rangeSet = RangeSet.of(decorations, true);
32 return setOccurrencesInteral(rangeSet);
33}
34
35export { findOccurrences };
diff --git a/language-web/src/main/js/editor/semanticHighlighting.ts b/language-web/src/main/js/editor/semanticHighlighting.ts
new file mode 100644
index 00000000..2aed421b
--- /dev/null
+++ b/language-web/src/main/js/editor/semanticHighlighting.ts
@@ -0,0 +1,24 @@
1import { RangeSet } from '@codemirror/rangeset';
2import type { TransactionSpec } from '@codemirror/state';
3import { Decoration } from '@codemirror/view';
4
5import { decorationSetExtension } from './decorationSetExtension';
6
7export interface IHighlightRange {
8 from: number;
9
10 to: number;
11
12 classes: string[];
13}
14
15const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension();
16
17export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec {
18 const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({
19 class: classes.map((c) => `cmt-problem-${c}`).join(' '),
20 }).range(from, to)), true);
21 return setSemanticHighlightingInternal(rangeSet);
22}
23
24export { semanticHighlighting };
diff --git a/language-web/src/main/js/index.tsx b/language-web/src/main/js/index.tsx
index 80c70f23..dfecde37 100644
--- a/language-web/src/main/js/index.tsx
+++ b/language-web/src/main/js/index.tsx
@@ -9,23 +9,34 @@ import { ThemeProvider } from './theme/ThemeProvider';
9import '../css/index.scss'; 9import '../css/index.scss';
10 10
11const initialValue = `class Family { 11const initialValue = `class Family {
12 contains Person[] members 12 contains Person[] members
13} 13}
14 14
15class Person { 15class Person {
16 Person[] children opposite parent 16 Person[] children opposite parent
17 Person[0..1] parent opposite children 17 Person[0..1] parent opposite children
18 int age 18 int age
19 TaxStatus taxStatus 19 TaxStatus taxStatus
20} 20}
21 21
22enum TaxStatus { 22enum TaxStatus {
23 child, student, adult, retired 23 child, student, adult, retired
24} 24}
25 25
26% A child cannot have any dependents. 26% A child cannot have any dependents.
27error invalidTaxStatus(Person p) <-> 27pred invalidTaxStatus(Person p) <->
28 taxStatus(p, child), children(p, _q). 28 taxStatus(p, child),
29 children(p, _q)
30 ; taxStatus(p, retired),
31 parent(p, q),
32 !taxStatus(q, retired).
33
34direct rule createChild(p):
35 children(p, newPerson) = unknown,
36 equals(newPerson, newPerson) = unknown
37 ~> new q,
38 children(p, q) = true,
39 taxStatus(q, child) = true.
29 40
30unique family. 41unique family.
31Family(family). 42Family(family).
@@ -44,8 +55,7 @@ age(bob, bobAge).
44scope Family = 1, Person += 5..10. 55scope Family = 1, Person += 5..10.
45`; 56`;
46 57
47const rootStore = new RootStore(); 58const rootStore = new RootStore(initialValue);
48rootStore.editorStore.updateValue(initialValue);
49 59
50const app = ( 60const app = (
51 <RootStoreProvider rootStore={rootStore}> 61 <RootStoreProvider rootStore={rootStore}>
diff --git a/language-web/src/main/js/language/folding.ts b/language-web/src/main/js/language/folding.ts
new file mode 100644
index 00000000..5d51f796
--- /dev/null
+++ b/language-web/src/main/js/language/folding.ts
@@ -0,0 +1,115 @@
1import { EditorState } from '@codemirror/state';
2import type { SyntaxNode } from '@lezer/common';
3
4export type FoldRange = { from: number, to: number };
5
6/**
7 * Folds a block comment between its delimiters.
8 *
9 * @param node the node to fold
10 * @returns the folding range or `null` is there is nothing to fold
11 */
12export function foldBlockComment(node: SyntaxNode): FoldRange {
13 return {
14 from: node.from + 2,
15 to: node.to - 2,
16 };
17}
18
19/**
20 * Folds a declaration after the first element if it appears on the opening line,
21 * otherwise folds after the opening keyword.
22 *
23 * @example
24 * First element on the opening line:
25 * ```
26 * scope Family = 1,
27 * Person += 5..10.
28 * ```
29 * becomes
30 * ```
31 * scope Family = 1,[...].
32 * ```
33 *
34 * @example
35 * First element not on the opening line:
36 * ```
37 * scope Family
38 * = 1,
39 * Person += 5..10.
40 * ```
41 * becomes
42 * ```
43 * scope [...].
44 * ```
45 *
46 * @param node the node to fold
47 * @param state the editor state
48 * @returns the folding range or `null` is there is nothing to fold
49 */
50export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null {
51 const { firstChild: open, lastChild: close } = node;
52 if (open === null || close === null) {
53 return null;
54 }
55 const { cursor } = open;
56 const lineEnd = state.doc.lineAt(open.from).to;
57 let foldFrom = open.to;
58 while (cursor.next() && cursor.from < lineEnd) {
59 if (cursor.type.name === ',') {
60 foldFrom = cursor.to;
61 break;
62 }
63 }
64 return {
65 from: foldFrom,
66 to: close.from,
67 };
68}
69
70/**
71 * Folds a node only if it has at least one sibling of the same type.
72 *
73 * The folding range will be the entire `node`.
74 *
75 * @param node the node to fold
76 * @returns the folding range or `null` is there is nothing to fold
77 */
78function foldWithSibling(node: SyntaxNode): FoldRange | null {
79 const { parent } = node;
80 if (parent === null) {
81 return null;
82 }
83 const { firstChild } = parent;
84 if (firstChild === null) {
85 return null;
86 }
87 const { cursor } = firstChild;
88 let nSiblings = 0;
89 while (cursor.nextSibling()) {
90 if (cursor.type === node.type) {
91 nSiblings += 1;
92 }
93 if (nSiblings >= 2) {
94 return {
95 from: node.from,
96 to: node.to,
97 };
98 }
99 }
100 return null;
101}
102
103export function foldWholeNode(node: SyntaxNode): FoldRange {
104 return {
105 from: node.from,
106 to: node.to,
107 };
108}
109
110export function foldConjunction(node: SyntaxNode): FoldRange | null {
111 if (node.parent?.type?.name === 'PredicateBody') {
112 return foldWithSibling(node);
113 }
114 return foldWholeNode(node);
115}
diff --git a/language-web/src/main/js/language/indentation.ts b/language-web/src/main/js/language/indentation.ts
new file mode 100644
index 00000000..78f0a750
--- /dev/null
+++ b/language-web/src/main/js/language/indentation.ts
@@ -0,0 +1,87 @@
1import { TreeIndentContext } from '@codemirror/language';
2
3/**
4 * Finds the `from` of first non-skipped token, if any,
5 * after the opening keyword in the first line of the declaration.
6 *
7 * Based on
8 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L246
9 *
10 * @param context the indentation context
11 * @returns the alignment or `null` if there is no token after the opening keyword
12 */
13function findAlignmentAfterOpening(context: TreeIndentContext): number | null {
14 const {
15 node: tree,
16 simulatedBreak,
17 } = context;
18 const openingToken = tree.childAfter(tree.from);
19 if (openingToken === null) {
20 return null;
21 }
22 const openingLine = context.state.doc.lineAt(openingToken.from);
23 const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from
24 ? openingLine.to
25 : Math.min(openingLine.to, simulatedBreak);
26 const { cursor } = openingToken;
27 while (cursor.next() && cursor.from < lineEnd) {
28 if (!cursor.type.isSkipped) {
29 return cursor.from;
30 }
31 }
32 return null;
33}
34
35/**
36 * Indents text after declarations by a single unit if it begins on a new line,
37 * otherwise it aligns with the text after the declaration.
38 *
39 * Based on
40 * https://github.com/codemirror/language/blob/cd7f7e66fa51ddbce96cf9396b1b6127d0ca4c94/src/indent.ts#L275
41 *
42 * @example
43 * Result with no hanging indent (indent unit = 2 spaces, units = 1):
44 * ```
45 * scope
46 * Family = 1,
47 * Person += 5..10.
48 * ```
49 *
50 * @example
51 * Result with hanging indent:
52 * ```
53 * scope Family = 1,
54 * Person += 5..10.
55 * ```
56 *
57 * @param context the indentation context
58 * @param units the number of units to indent
59 * @returns the desired indentation level
60 */
61function indentDeclarationStrategy(context: TreeIndentContext, units: number): number {
62 const alignment = findAlignmentAfterOpening(context);
63 if (alignment !== null) {
64 return context.column(alignment);
65 }
66 return context.baseIndent + units * context.unit;
67}
68
69export function indentBlockComment(): number {
70 // Do not indent.
71 return -1;
72}
73
74export function indentDeclaration(context: TreeIndentContext): number {
75 return indentDeclarationStrategy(context, 1);
76}
77
78export function indentPredicateOrRule(context: TreeIndentContext): number {
79 const clauseIndent = indentDeclarationStrategy(context, 1);
80 if (/^\s+(;|\.)/.exec(context.textAfter) !== null) {
81 return clauseIndent - 2;
82 }
83 if (/^\s+(~>)/.exec(context.textAfter) !== null) {
84 return clauseIndent - 3;
85 }
86 return clauseIndent;
87}
diff --git a/language-web/src/main/js/language/problem.grammar b/language-web/src/main/js/language/problem.grammar
new file mode 100644
index 00000000..c242a4ba
--- /dev/null
+++ b/language-web/src/main/js/language/problem.grammar
@@ -0,0 +1,145 @@
1@top Problem { statement* }
2
3statement {
4 ProblemDeclaration {
5 ckw<"problem"> QualifiedName "."
6 } |
7 ClassDefinition {
8 ckw<"abstract">? ckw<"class"> RelationName
9 (ckw<"extends"> sep<",", RelationName>)?
10 (ClassBody { "{" ReferenceDeclaration* "}" } | ".")
11 } |
12 EnumDefinition {
13 ckw<"enum"> RelationName
14 (EnumBody { "{" sep<",", UniqueNodeName> "}" } | ".")
15 } |
16 PredicateDefinition {
17 (ckw<"error"> ckw<"pred">? | ckw<"direct">? ckw<"pred">)
18 RelationName ParameterList<Parameter>?
19 PredicateBody { ("<->" sep<OrOp, Conjunction>)? "." }
20 } |
21 RuleDefinition {
22 ckw<"direct">? ckw<"rule">
23 RuleName ParameterList<Parameter>?
24 RuleBody { ":" sep<OrOp, Conjunction> "~>" sep<OrOp, Action> "." }
25 } |
26 Assertion {
27 ckw<"default">? (NotOp | UnknownOp)? RelationName
28 ParameterList<AssertionArgument> (":" LogicValue)? "."
29 } |
30 NodeValueAssertion {
31 UniqueNodeName ":" Constant "."
32 } |
33 UniqueDeclaration {
34 ckw<"unique"> sep<",", UniqueNodeName> "."
35 } |
36 ScopeDeclaration {
37 ckw<"scope"> sep<",", ScopeElement> "."
38 }
39}
40
41ReferenceDeclaration {
42 (kw<"refers"> | kw<"contains">)?
43 RelationName
44 RelationName
45 ( "[" Multiplicity? "]" )?
46 (kw<"opposite"> RelationName)?
47 ";"?
48}
49
50Parameter { RelationName? VariableName }
51
52Conjunction { ("," | Literal)+ }
53
54OrOp { ";" }
55
56Literal { NotOp? Atom (("=" | ":") sep1<"|", LogicValue>)? }
57
58Atom { RelationName "+"? ParameterList<Argument> }
59
60Action { ("," | ActionLiteral)+ }
61
62ActionLiteral {
63 ckw<"new"> VariableName |
64 ckw<"delete"> VariableName |
65 Literal
66}
67
68Argument { VariableName | Constant }
69
70AssertionArgument { NodeName | StarArgument | Constant }
71
72Constant { Real | String }
73
74LogicValue {
75 ckw<"true"> | ckw<"false"> | ckw<"unknown"> | ckw<"error">
76}
77
78ScopeElement { RelationName ("=" | "+=") Multiplicity }
79
80Multiplicity { (IntMult "..")? (IntMult | StarMult)}
81
82RelationName { QualifiedName }
83
84RuleName { QualifiedName }
85
86UniqueNodeName { QualifiedName }
87
88VariableName { QualifiedName }
89
90NodeName { QualifiedName }
91
92QualifiedName { identifier ("::" identifier)* }
93
94kw<term> { @specialize[@name={term}]<identifier, term> }
95
96ckw<term> { @extend[@name={term}]<identifier, term> }
97
98ParameterList<content> { "(" sep<",", content> ")" }
99
100sep<separator, content> { sep1<separator, content>? }
101
102sep1<separator, content> { content (separator content)* }
103
104@skip { LineComment | BlockComment | whitespace }
105
106@tokens {
107 whitespace { std.whitespace+ }
108
109 LineComment { ("//" | "%") ![\n]* }
110
111 BlockComment { "/*" blockCommentRest }
112
113 blockCommentRest { ![*] blockCommentRest | "*" blockCommentAfterStar }
114
115 blockCommentAfterStar { "/" | "*" blockCommentAfterStar | ![/*] blockCommentRest }
116
117 @precedence { BlockComment, LineComment }
118
119 identifier { $[A-Za-z_] $[a-zA-Z0-9_]* }
120
121 int { $[0-9]+ }
122
123 IntMult { int }
124
125 StarMult { "*" }
126
127 Real { "-"? (exponential | int ("." (int | exponential))?) }
128
129 exponential { int ("e" | "E") ("+" | "-")? int }
130
131 String {
132 "'" (![\\'\n] | "\\" ![\n] | "\\\n")+ "'" |
133 "\"" (![\\"\n] | "\\" (![\n] | "\n"))* "\""
134 }
135
136 NotOp { "!" }
137
138 UnknownOp { "?" }
139
140 StarArgument { "*" }
141
142 "{" "}" "(" ")" "[" "]" "." ".." "," ":" "<->" "~>"
143}
144
145@detectDelim
diff --git a/language-web/src/main/js/language/problemLanguageSupport.ts b/language-web/src/main/js/language/problemLanguageSupport.ts
new file mode 100644
index 00000000..ab1c55f9
--- /dev/null
+++ b/language-web/src/main/js/language/problemLanguageSupport.ts
@@ -0,0 +1,92 @@
1import { styleTags, tags as t } from '@codemirror/highlight';
2import {
3 foldInside,
4 foldNodeProp,
5 indentNodeProp,
6 indentUnit,
7 LanguageSupport,
8 LRLanguage,
9} from '@codemirror/language';
10import { LRParser } from '@lezer/lr';
11
12import { parser } from '../../../../build/generated/sources/lezer/problem';
13import {
14 foldBlockComment,
15 foldConjunction,
16 foldDeclaration,
17 foldWholeNode,
18} from './folding';
19import {
20 indentBlockComment,
21 indentDeclaration,
22 indentPredicateOrRule,
23} from './indentation';
24
25const parserWithMetadata = (parser as LRParser).configure({
26 props: [
27 styleTags({
28 LineComment: t.lineComment,
29 BlockComment: t.blockComment,
30 'problem class enum pred rule unique scope': t.definitionKeyword,
31 'abstract extends refers contains opposite error direct default': t.modifier,
32 'true false unknown error': t.keyword,
33 'new delete': t.operatorKeyword,
34 NotOp: t.keyword,
35 UnknownOp: t.keyword,
36 OrOp: t.keyword,
37 StarArgument: t.keyword,
38 'IntMult StarMult Real': t.number,
39 StarMult: t.number,
40 String: t.string,
41 'RelationName/QualifiedName': t.typeName,
42 'RuleName/QualifiedName': t.macroName,
43 'UniqueNodeName/QualifiedName': t.atom,
44 'VariableName/QualifiedName': t.variableName,
45 '{ }': t.brace,
46 '( )': t.paren,
47 '[ ]': t.squareBracket,
48 '. .. , :': t.separator,
49 '<-> ~>': t.definitionOperator,
50 }),
51 indentNodeProp.add({
52 ProblemDeclaration: indentDeclaration,
53 UniqueDeclaration: indentDeclaration,
54 ScopeDeclaration: indentDeclaration,
55 PredicateBody: indentPredicateOrRule,
56 RuleBody: indentPredicateOrRule,
57 BlockComment: indentBlockComment,
58 }),
59 foldNodeProp.add({
60 ClassBody: foldInside,
61 EnumBody: foldInside,
62 ParameterList: foldInside,
63 PredicateBody: foldInside,
64 RuleBody: foldInside,
65 Conjunction: foldConjunction,
66 Action: foldWholeNode,
67 UniqueDeclaration: foldDeclaration,
68 ScopeDeclaration: foldDeclaration,
69 BlockComment: foldBlockComment,
70 }),
71 ],
72});
73
74const problemLanguage = LRLanguage.define({
75 parser: parserWithMetadata,
76 languageData: {
77 commentTokens: {
78 block: {
79 open: '/*',
80 close: '*/',
81 },
82 line: '%',
83 },
84 indentOnInput: /^\s*(?:\{|\}|\(|\)|;|\.|~>)$/,
85 },
86});
87
88export function problemLanguageSupport(): LanguageSupport {
89 return new LanguageSupport(problemLanguage, [
90 indentUnit.of(' '),
91 ]);
92}
diff --git a/language-web/src/main/js/theme/ThemeStore.ts b/language-web/src/main/js/theme/ThemeStore.ts
index 3bbea3a1..ffaf6dde 100644
--- a/language-web/src/main/js/theme/ThemeStore.ts
+++ b/language-web/src/main/js/theme/ThemeStore.ts
@@ -42,6 +42,9 @@ export class ThemeStore {
42 secondary: { 42 secondary: {
43 main: themeData.secondary, 43 main: themeData.secondary,
44 }, 44 },
45 error: {
46 main: themeData.secondary,
47 },
45 text: { 48 text: {
46 primary: themeData.foregroundHighlight, 49 primary: themeData.foregroundHighlight,
47 secondary: themeData.foreground, 50 secondary: themeData.foreground,
@@ -51,6 +54,10 @@ export class ThemeStore {
51 return responsiveFontSizes(materialUiTheme); 54 return responsiveFontSizes(materialUiTheme);
52 } 55 }
53 56
57 get darkMode(): boolean {
58 return this.currentThemeData.paletteMode === 'dark';
59 }
60
54 get className(): string { 61 get className(): string {
55 return this.currentThemeData.className; 62 return this.currentThemeData.className;
56 } 63 }
diff --git a/language-web/src/main/js/utils/ConditionVariable.ts b/language-web/src/main/js/utils/ConditionVariable.ts
new file mode 100644
index 00000000..0910dfa6
--- /dev/null
+++ b/language-web/src/main/js/utils/ConditionVariable.ts
@@ -0,0 +1,64 @@
1import { getLogger } from './logger';
2import { PendingTask } from './PendingTask';
3
4const log = getLogger('utils.ConditionVariable');
5
6export type Condition = () => boolean;
7
8export class ConditionVariable {
9 condition: Condition;
10
11 defaultTimeout: number;
12
13 listeners: PendingTask<void>[] = [];
14
15 constructor(condition: Condition, defaultTimeout = 0) {
16 this.condition = condition;
17 this.defaultTimeout = defaultTimeout;
18 }
19
20 async waitFor(timeoutMs: number | null = null): Promise<void> {
21 if (this.condition()) {
22 return;
23 }
24 const timeoutOrDefault = timeoutMs || this.defaultTimeout;
25 let nowMs = Date.now();
26 const endMs = nowMs + timeoutOrDefault;
27 while (!this.condition() && nowMs < endMs) {
28 const remainingMs = endMs - nowMs;
29 const promise = new Promise<void>((resolve, reject) => {
30 if (this.condition()) {
31 resolve();
32 return;
33 }
34 const task = new PendingTask(resolve, reject, remainingMs);
35 this.listeners.push(task);
36 });
37 // We must keep waiting until the update has completed,
38 // so the tasks can't be started in parallel.
39 // eslint-disable-next-line no-await-in-loop
40 await promise;
41 nowMs = Date.now();
42 }
43 if (!this.condition()) {
44 log.error('Condition still does not hold after', timeoutOrDefault, 'ms');
45 throw new Error('Failed to wait for condition');
46 }
47 }
48
49 notifyAll(): void {
50 this.clearListenersWith((listener) => listener.resolve());
51 }
52
53 rejectAll(error: unknown): void {
54 this.clearListenersWith((listener) => listener.reject(error));
55 }
56
57 private clearListenersWith(callback: (listener: PendingTask<void>) => void) {
58 // Copy `listeners` so that we don't get into a race condition
59 // if one of the listeners adds another listener.
60 const { listeners } = this;
61 this.listeners = [];
62 listeners.forEach(callback);
63 }
64}
diff --git a/language-web/src/main/js/utils/PendingTask.ts b/language-web/src/main/js/utils/PendingTask.ts
new file mode 100644
index 00000000..de59a99b
--- /dev/null
+++ b/language-web/src/main/js/utils/PendingTask.ts
@@ -0,0 +1,60 @@
1import { getLogger } from './logger';
2
3const log = getLogger('utils.PendingTask');
4
5export class PendingTask<T> {
6 private readonly resolveCallback: (value: T) => void;
7
8 private readonly rejectCallback: (reason?: unknown) => void;
9
10 private resolved = false;
11
12 private timeout: NodeJS.Timeout | null;
13
14 constructor(
15 resolveCallback: (value: T) => void,
16 rejectCallback: (reason?: unknown) => void,
17 timeoutMs?: number,
18 timeoutCallback?: () => void,
19 ) {
20 this.resolveCallback = resolveCallback;
21 this.rejectCallback = rejectCallback;
22 if (timeoutMs) {
23 this.timeout = setTimeout(() => {
24 if (!this.resolved) {
25 this.reject(new Error('Request timed out'));
26 if (timeoutCallback) {
27 timeoutCallback();
28 }
29 }
30 }, timeoutMs);
31 } else {
32 this.timeout = null;
33 }
34 }
35
36 resolve(value: T): void {
37 if (this.resolved) {
38 log.warn('Trying to resolve already resolved promise');
39 return;
40 }
41 this.markResolved();
42 this.resolveCallback(value);
43 }
44
45 reject(reason?: unknown): void {
46 if (this.resolved) {
47 log.warn('Trying to reject already resolved promise');
48 return;
49 }
50 this.markResolved();
51 this.rejectCallback(reason);
52 }
53
54 private markResolved() {
55 this.resolved = true;
56 if (this.timeout !== null) {
57 clearTimeout(this.timeout);
58 }
59 }
60}
diff --git a/language-web/src/main/js/utils/Timer.ts b/language-web/src/main/js/utils/Timer.ts
new file mode 100644
index 00000000..efde6633
--- /dev/null
+++ b/language-web/src/main/js/utils/Timer.ts
@@ -0,0 +1,33 @@
1export class Timer {
2 readonly callback: () => void;
3
4 readonly defaultTimeout: number;
5
6 timeout: NodeJS.Timeout | null = null;
7
8 constructor(callback: () => void, defaultTimeout = 0) {
9 this.callback = () => {
10 this.timeout = null;
11 callback();
12 };
13 this.defaultTimeout = defaultTimeout;
14 }
15
16 schedule(timeout: number | null = null): void {
17 if (this.timeout === null) {
18 this.timeout = setTimeout(this.callback, timeout || this.defaultTimeout);
19 }
20 }
21
22 reschedule(timeout: number | null = null): void {
23 this.cancel();
24 this.schedule(timeout);
25 }
26
27 cancel(): void {
28 if (this.timeout !== null) {
29 clearTimeout(this.timeout);
30 this.timeout = null;
31 }
32 }
33}
diff --git a/language-web/src/main/js/logging.tsx b/language-web/src/main/js/utils/logger.ts
index 25f50f19..306d122c 100644
--- a/language-web/src/main/js/logging.tsx
+++ b/language-web/src/main/js/utils/logger.ts
@@ -2,7 +2,7 @@ import styles, { CSPair } from 'ansi-styles';
2import log from 'loglevel'; 2import log from 'loglevel';
3import * as prefix from 'loglevel-plugin-prefix'; 3import * as prefix from 'loglevel-plugin-prefix';
4 4
5const colors: Record<string, CSPair> = { 5const colors: Partial<Record<string, CSPair>> = {
6 TRACE: styles.magenta, 6 TRACE: styles.magenta,
7 DEBUG: styles.cyan, 7 DEBUG: styles.cyan,
8 INFO: styles.blue, 8 INFO: styles.blue,
diff --git a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js b/language-web/src/main/js/xtext/CodeMirrorEditorContext.js
deleted file mode 100644
index b829c680..00000000
--- a/language-web/src/main/js/xtext/CodeMirrorEditorContext.js
+++ /dev/null
@@ -1,111 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define([], function() {
11
12 /**
13 * An editor context mediates between the Xtext services and the CodeMirror editor framework.
14 */
15 function CodeMirrorEditorContext(editor) {
16 this._editor = editor;
17 this._serverState = {};
18 this._serverStateListeners = [];
19 this._dirty = false;
20 this._dirtyStateListeners = [];
21 };
22
23 CodeMirrorEditorContext.prototype = {
24
25 getServerState: function() {
26 return this._serverState;
27 },
28
29 updateServerState: function(currentText, currentStateId) {
30 this._serverState.text = currentText;
31 this._serverState.stateId = currentStateId;
32 return this._serverStateListeners;
33 },
34
35 addServerStateListener: function(listener) {
36 this._serverStateListeners.push(listener);
37 },
38
39 getCaretOffset: function() {
40 var editor = this._editor;
41 return editor.indexFromPos(editor.getCursor());
42 },
43
44 getLineStart: function(lineNumber) {
45 var editor = this._editor;
46 return editor.indexFromPos({line: lineNumber, ch: 0});
47 },
48
49 getSelection: function() {
50 var editor = this._editor;
51 return {
52 start: editor.indexFromPos(editor.getCursor('from')),
53 end: editor.indexFromPos(editor.getCursor('to'))
54 };
55 },
56
57 getText: function(start, end) {
58 var editor = this._editor;
59 if (start && end) {
60 return editor.getRange(editor.posFromIndex(start), editor.posFromIndex(end));
61 } else {
62 return editor.getValue();
63 }
64 },
65
66 isDirty: function() {
67 return !this._clean;
68 },
69
70 setDirty: function(dirty) {
71 if (dirty != this._dirty) {
72 for (var i = 0; i < this._dirtyStateListeners.length; i++) {
73 this._dirtyStateListeners[i](dirty);
74 }
75 }
76 this._dirty = dirty;
77 },
78
79 addDirtyStateListener: function(listener) {
80 this._dirtyStateListeners.push(listener);
81 },
82
83 clearUndoStack: function() {
84 this._editor.clearHistory();
85 },
86
87 setCaretOffset: function(offset) {
88 var editor = this._editor;
89 editor.setCursor(editor.posFromIndex(offset));
90 },
91
92 setSelection: function(selection) {
93 var editor = this._editor;
94 editor.setSelection(editor.posFromIndex(selection.start), editor.posFromIndex(selection.end));
95 },
96
97 setText: function(text, start, end) {
98 var editor = this._editor;
99 if (!start)
100 start = 0;
101 if (!end)
102 end = editor.getValue().length;
103 var cursor = editor.getCursor();
104 editor.replaceRange(text, editor.posFromIndex(start), editor.posFromIndex(end));
105 editor.setCursor(cursor);
106 }
107
108 };
109
110 return CodeMirrorEditorContext;
111}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts
new file mode 100644
index 00000000..f085c5b1
--- /dev/null
+++ b/language-web/src/main/js/xtext/ContentAssistService.ts
@@ -0,0 +1,177 @@
1import type {
2 Completion,
3 CompletionContext,
4 CompletionResult,
5} from '@codemirror/autocomplete';
6import type { Transaction } from '@codemirror/state';
7import escapeStringRegexp from 'escape-string-regexp';
8
9import type { UpdateService } from './UpdateService';
10import { getLogger } from '../utils/logger';
11import type { IContentAssistEntry } from './xtextServiceResults';
12
13const PROPOSALS_LIMIT = 1000;
14
15const IDENTIFIER_REGEXP_STR = '[a-zA-Z0-9_]*';
16
17const HIGH_PRIORITY_KEYWORDS = ['<->'];
18
19const QUALIFIED_NAME_SEPARATOR_REGEXP = /::/g;
20
21const log = getLogger('xtext.ContentAssistService');
22
23function createCompletion(entry: IContentAssistEntry): Completion {
24 let boost;
25 switch (entry.kind) {
26 case 'KEYWORD':
27 // Some hard-to-type operators should be on top.
28 boost = HIGH_PRIORITY_KEYWORDS.includes(entry.proposal) ? 10 : -99;
29 break;
30 case 'TEXT':
31 case 'SNIPPET':
32 boost = -90;
33 break;
34 default: {
35 // Penalize qualified names (vs available unqualified names).
36 const extraSegments = entry.proposal.match(QUALIFIED_NAME_SEPARATOR_REGEXP)?.length || 0;
37 boost = Math.max(-5 * extraSegments, -50);
38 }
39 break;
40 }
41 return {
42 label: entry.proposal,
43 detail: entry.description,
44 info: entry.documentation,
45 type: entry.kind?.toLowerCase(),
46 boost,
47 };
48}
49
50function computeSpan(prefix: string, entryCount: number) {
51 const escapedPrefix = escapeStringRegexp(prefix);
52 if (entryCount < PROPOSALS_LIMIT) {
53 // Proposals with the current prefix fit the proposals limit.
54 // We can filter client side as long as the current prefix is preserved.
55 return new RegExp(`^${escapedPrefix}${IDENTIFIER_REGEXP_STR}$`);
56 }
57 // The current prefix overflows the proposals limits,
58 // so we have to fetch the completions again on the next keypress.
59 // Hopefully, it'll return a shorter list and we'll be able to filter client side.
60 return new RegExp(`^${escapedPrefix}$`);
61}
62
63export class ContentAssistService {
64 private readonly updateService: UpdateService;
65
66 private lastCompletion: CompletionResult | null = null;
67
68 constructor(updateService: UpdateService) {
69 this.updateService = updateService;
70 }
71
72 onTransaction(transaction: Transaction): void {
73 if (this.shouldInvalidateCachedCompletion(transaction)) {
74 this.lastCompletion = null;
75 }
76 }
77
78 async contentAssist(context: CompletionContext): Promise<CompletionResult> {
79 const tokenBefore = context.tokenBefore(['QualifiedName']);
80 let range: { from: number, to: number };
81 let prefix = '';
82 if (tokenBefore === null) {
83 if (!context.explicit) {
84 return {
85 from: context.pos,
86 options: [],
87 };
88 }
89 range = {
90 from: context.pos,
91 to: context.pos,
92 };
93 prefix = '';
94 } else {
95 range = {
96 from: tokenBefore.from,
97 to: tokenBefore.to,
98 };
99 const prefixLength = context.pos - tokenBefore.from;
100 if (prefixLength > 0) {
101 prefix = tokenBefore.text.substring(0, context.pos - tokenBefore.from);
102 }
103 }
104 if (!context.explicit && this.shouldReturnCachedCompletion(tokenBefore)) {
105 log.trace('Returning cached completion result');
106 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
107 return {
108 ...this.lastCompletion as CompletionResult,
109 ...range,
110 };
111 }
112 this.lastCompletion = null;
113 const entries = await this.updateService.fetchContentAssist({
114 resource: this.updateService.resourceName,
115 serviceType: 'assist',
116 caretOffset: context.pos,
117 proposalsLimit: PROPOSALS_LIMIT,
118 }, context);
119 if (context.aborted) {
120 return {
121 ...range,
122 options: [],
123 };
124 }
125 const options: Completion[] = [];
126 entries.forEach((entry) => {
127 options.push(createCompletion(entry));
128 });
129 log.debug('Fetched', options.length, 'completions from server');
130 this.lastCompletion = {
131 ...range,
132 options,
133 span: computeSpan(prefix, entries.length),
134 };
135 return this.lastCompletion;
136 }
137
138 private shouldReturnCachedCompletion(
139 token: { from: number, to: number, text: string } | null,
140 ) {
141 if (token === null || this.lastCompletion === null) {
142 return false;
143 }
144 const { from, to, text } = token;
145 const { from: lastFrom, to: lastTo, span } = this.lastCompletion;
146 if (!lastTo) {
147 return true;
148 }
149 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
150 return from >= transformedFrom && to <= transformedTo && span && span.exec(text);
151 }
152
153 private shouldInvalidateCachedCompletion(transaction: Transaction) {
154 if (!transaction.docChanged || this.lastCompletion === null) {
155 return false;
156 }
157 const { from: lastFrom, to: lastTo } = this.lastCompletion;
158 if (!lastTo) {
159 return true;
160 }
161 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo);
162 let invalidate = false;
163 transaction.changes.iterChangedRanges((fromA, toA) => {
164 if (fromA < transformedFrom || toA > transformedTo) {
165 invalidate = true;
166 }
167 });
168 return invalidate;
169 }
170
171 private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] {
172 const changes = this.updateService.computeChangesSinceLastUpdate();
173 const transformedFrom = changes.mapPos(lastFrom);
174 const transformedTo = changes.mapPos(lastTo, 1);
175 return [transformedFrom, transformedTo];
176 }
177}
diff --git a/language-web/src/main/js/xtext/HighlightingService.ts b/language-web/src/main/js/xtext/HighlightingService.ts
new file mode 100644
index 00000000..fc3e9e53
--- /dev/null
+++ b/language-web/src/main/js/xtext/HighlightingService.ts
@@ -0,0 +1,43 @@
1import type { EditorStore } from '../editor/EditorStore';
2import type { IHighlightRange } from '../editor/semanticHighlighting';
3import type { UpdateService } from './UpdateService';
4import { getLogger } from '../utils/logger';
5import { isHighlightingResult } from './xtextServiceResults';
6
7const log = getLogger('xtext.ValidationService');
8
9export class HighlightingService {
10 private readonly store: EditorStore;
11
12 private readonly updateService: UpdateService;
13
14 constructor(store: EditorStore, updateService: UpdateService) {
15 this.store = store;
16 this.updateService = updateService;
17 }
18
19 onPush(push: unknown): void {
20 if (!isHighlightingResult(push)) {
21 log.error('Invalid highlighting result', push);
22 return;
23 }
24 const allChanges = this.updateService.computeChangesSinceLastUpdate();
25 const ranges: IHighlightRange[] = [];
26 push.regions.forEach(({ offset, length, styleClasses }) => {
27 if (styleClasses.length === 0) {
28 return;
29 }
30 const from = allChanges.mapPos(offset);
31 const to = allChanges.mapPos(offset + length);
32 if (to <= from) {
33 return;
34 }
35 ranges.push({
36 from,
37 to,
38 classes: styleClasses,
39 });
40 });
41 this.store.updateSemanticHighlighting(ranges);
42 }
43}
diff --git a/language-web/src/main/js/xtext/OccurrencesService.ts b/language-web/src/main/js/xtext/OccurrencesService.ts
new file mode 100644
index 00000000..d1dec9e9
--- /dev/null
+++ b/language-web/src/main/js/xtext/OccurrencesService.ts
@@ -0,0 +1,116 @@
1import { Transaction } from '@codemirror/state';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences';
5import type { UpdateService } from './UpdateService';
6import { getLogger } from '../utils/logger';
7import { Timer } from '../utils/Timer';
8import { XtextWebSocketClient } from './XtextWebSocketClient';
9import {
10 isOccurrencesResult,
11 isServiceConflictResult,
12 ITextRegion,
13} from './xtextServiceResults';
14
15const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
16
17// Must clear occurrences asynchronously from `onTransaction`,
18// because we must not emit a conflicting transaction when handling the pending transaction.
19const CLEAR_OCCURRENCES_TIMEOUT_MS = 10;
20
21const log = getLogger('xtext.OccurrencesService');
22
23function transformOccurrences(regions: ITextRegion[]): IOccurrence[] {
24 const occurrences: IOccurrence[] = [];
25 regions.forEach(({ offset, length }) => {
26 if (length > 0) {
27 occurrences.push({
28 from: offset,
29 to: offset + length,
30 });
31 }
32 });
33 return occurrences;
34}
35
36export class OccurrencesService {
37 private readonly store: EditorStore;
38
39 private readonly webSocketClient: XtextWebSocketClient;
40
41 private readonly updateService: UpdateService;
42
43 private hasOccurrences = false;
44
45 private readonly findOccurrencesTimer = new Timer(() => {
46 this.handleFindOccurrences();
47 }, FIND_OCCURRENCES_TIMEOUT_MS);
48
49 private readonly clearOccurrencesTimer = new Timer(() => {
50 this.clearOccurrences();
51 }, CLEAR_OCCURRENCES_TIMEOUT_MS);
52
53 constructor(
54 store: EditorStore,
55 webSocketClient: XtextWebSocketClient,
56 updateService: UpdateService,
57 ) {
58 this.store = store;
59 this.webSocketClient = webSocketClient;
60 this.updateService = updateService;
61 }
62
63 onTransaction(transaction: Transaction): void {
64 if (transaction.docChanged) {
65 this.clearOccurrencesTimer.schedule();
66 this.findOccurrencesTimer.reschedule();
67 }
68 if (transaction.isUserEvent('select')) {
69 this.findOccurrencesTimer.reschedule();
70 }
71 }
72
73 private handleFindOccurrences() {
74 this.clearOccurrencesTimer.cancel();
75 this.updateOccurrences().catch((error) => {
76 log.error('Unexpected error while updating occurrences', error);
77 this.clearOccurrences();
78 });
79 }
80
81 private async updateOccurrences() {
82 await this.updateService.update();
83 const result = await this.webSocketClient.send({
84 resource: this.updateService.resourceName,
85 serviceType: 'occurrences',
86 expectedStateId: this.updateService.xtextStateId,
87 caretOffset: this.store.state.selection.main.head,
88 });
89 const allChanges = this.updateService.computeChangesSinceLastUpdate();
90 if (!allChanges.empty
91 || (isServiceConflictResult(result) && result.conflict === 'canceled')) {
92 // Stale occurrences result, the user already made some changes.
93 // We can safely ignore the occurrences and schedule a new find occurrences call.
94 this.clearOccurrences();
95 this.findOccurrencesTimer.schedule();
96 return;
97 }
98 if (!isOccurrencesResult(result) || result.stateId !== this.updateService.xtextStateId) {
99 log.error('Unexpected occurrences result', result);
100 this.clearOccurrences();
101 return;
102 }
103 const write = transformOccurrences(result.writeRegions);
104 const read = transformOccurrences(result.readRegions);
105 this.hasOccurrences = write.length > 0 || read.length > 0;
106 log.debug('Found', write.length, 'write and', read.length, 'read occurrences');
107 this.store.updateOccurrences(write, read);
108 }
109
110 private clearOccurrences() {
111 if (this.hasOccurrences) {
112 this.store.updateOccurrences([], []);
113 this.hasOccurrences = false;
114 }
115 }
116}
diff --git a/language-web/src/main/js/xtext/ServiceBuilder.js b/language-web/src/main/js/xtext/ServiceBuilder.js
deleted file mode 100644
index 57fcb310..00000000
--- a/language-web/src/main/js/xtext/ServiceBuilder.js
+++ /dev/null
@@ -1,285 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 ******************************************************************************/
9
10define([
11 'jquery',
12 'xtext/services/XtextService',
13 'xtext/services/LoadResourceService',
14 'xtext/services/SaveResourceService',
15 'xtext/services/HighlightingService',
16 'xtext/services/ValidationService',
17 'xtext/services/UpdateService',
18 'xtext/services/ContentAssistService',
19 'xtext/services/HoverService',
20 'xtext/services/OccurrencesService',
21 'xtext/services/FormattingService',
22 '../logging',
23], function(jQuery, XtextService, LoadResourceService, SaveResourceService, HighlightingService,
24 ValidationService, UpdateService, ContentAssistService, HoverService, OccurrencesService,
25 FormattingService, logging) {
26
27 /**
28 * Builder class for the Xtext services.
29 */
30 function ServiceBuilder(xtextServices) {
31 this.services = xtextServices;
32 };
33
34 /**
35 * Create all the available Xtext services depending on the configuration.
36 */
37 ServiceBuilder.prototype.createServices = function() {
38 var services = this.services;
39 var options = services.options;
40 var editorContext = services.editorContext;
41 editorContext.xtextServices = services;
42 var self = this;
43 if (!options.serviceUrl) {
44 if (!options.baseUrl)
45 options.baseUrl = '/';
46 else if (options.baseUrl.charAt(0) != '/')
47 options.baseUrl = '/' + options.baseUrl;
48 options.serviceUrl = window.location.protocol + '//' + window.location.host + options.baseUrl + 'xtext-service';
49 }
50 if (options.resourceId) {
51 if (!options.xtextLang)
52 options.xtextLang = options.resourceId.split(/[?#]/)[0].split('.').pop();
53 if (options.loadFromServer === undefined)
54 options.loadFromServer = true;
55 if (options.loadFromServer && this.setupPersistenceServices) {
56 services.loadResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, false);
57 services.loadResource = function(addParams) {
58 return services.loadResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
59 }
60 services.saveResourceService = new SaveResourceService(options.serviceUrl, options.resourceId);
61 services.saveResource = function(addParams) {
62 return services.saveResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
63 }
64 services.revertResourceService = new LoadResourceService(options.serviceUrl, options.resourceId, true);
65 services.revertResource = function(addParams) {
66 return services.revertResourceService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
67 }
68 this.setupPersistenceServices();
69 services.loadResource();
70 }
71 } else {
72 if (options.loadFromServer === undefined)
73 options.loadFromServer = false;
74 if (options.xtextLang) {
75 var randomId = Math.floor(Math.random() * 2147483648).toString(16);
76 options.resourceId = randomId + '.' + options.xtextLang;
77 }
78 }
79
80 if (this.setupSyntaxHighlighting) {
81 this.setupSyntaxHighlighting();
82 }
83 if (options.enableHighlightingService || options.enableHighlightingService === undefined) {
84 services.highlightingService = new HighlightingService(options.serviceUrl, options.resourceId);
85 services.computeHighlighting = function(addParams) {
86 return services.highlightingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
87 }
88 }
89 if (options.enableValidationService || options.enableValidationService === undefined) {
90 services.validationService = new ValidationService(options.serviceUrl, options.resourceId);
91 services.validate = function(addParams) {
92 return services.validationService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
93 }
94 }
95 if (this.setupUpdateService) {
96 function refreshDocument() {
97 if (services.highlightingService && self.doHighlighting) {
98 services.highlightingService.setState(undefined);
99 self.doHighlighting();
100 }
101 if (services.validationService && self.doValidation) {
102 services.validationService.setState(undefined);
103 self.doValidation();
104 }
105 }
106 if (!options.sendFullText) {
107 services.updateService = new UpdateService(options.serviceUrl, options.resourceId);
108 services.update = function(addParams) {
109 return services.updateService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
110 }
111 if (services.saveResourceService)
112 services.saveResourceService._updateService = services.updateService;
113 editorContext.addServerStateListener(refreshDocument);
114 }
115 this.setupUpdateService(refreshDocument);
116 }
117 if ((options.enableContentAssistService || options.enableContentAssistService === undefined)
118 && this.setupContentAssistService) {
119 services.contentAssistService = new ContentAssistService(options.serviceUrl, options.resourceId, services.updateService);
120 services.getContentAssist = function(addParams) {
121 return services.contentAssistService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
122 }
123 this.setupContentAssistService();
124 }
125 if ((options.enableHoverService || options.enableHoverService === undefined)
126 && this.setupHoverService) {
127 services.hoverService = new HoverService(options.serviceUrl, options.resourceId, services.updateService);
128 services.getHoverInfo = function(addParams) {
129 return services.hoverService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
130 }
131 this.setupHoverService();
132 }
133 if ((options.enableOccurrencesService || options.enableOccurrencesService === undefined)
134 && this.setupOccurrencesService) {
135 services.occurrencesService = new OccurrencesService(options.serviceUrl, options.resourceId, services.updateService);
136 services.getOccurrences = function(addParams) {
137 return services.occurrencesService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
138 }
139 this.setupOccurrencesService();
140 }
141 if ((options.enableFormattingService || options.enableFormattingService === undefined)
142 && this.setupFormattingService) {
143 services.formattingService = new FormattingService(options.serviceUrl, options.resourceId, services.updateService);
144 services.format = function(addParams) {
145 return services.formattingService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
146 }
147 this.setupFormattingService();
148 }
149 if (options.enableGeneratorService || options.enableGeneratorService === undefined) {
150 services.generatorService = new XtextService();
151 services.generatorService.initialize(services, 'generate');
152 services.generatorService._initServerData = function(serverData, editorContext, params) {
153 if (params.allArtifacts)
154 serverData.allArtifacts = params.allArtifacts;
155 else if (params.artifactId)
156 serverData.artifact = params.artifactId;
157 if (params.includeContent !== undefined)
158 serverData.includeContent = params.includeContent;
159 }
160 services.generate = function(addParams) {
161 return services.generatorService.invoke(editorContext, ServiceBuilder.mergeOptions(addParams, options));
162 }
163 }
164
165 if (options.dirtyElement) {
166 var doc = options.document || document;
167 var dirtyElement;
168 if (typeof(options.dirtyElement) === 'string')
169 dirtyElement = jQuery('#' + options.dirtyElement, doc);
170 else
171 dirtyElement = jQuery(options.dirtyElement);
172 var dirtyStatusClass = options.dirtyStatusClass;
173 if (!dirtyStatusClass)
174 dirtyStatusClass = 'dirty';
175 editorContext.addDirtyStateListener(function(dirty) {
176 if (dirty)
177 dirtyElement.addClass(dirtyStatusClass);
178 else
179 dirtyElement.removeClass(dirtyStatusClass);
180 });
181 }
182
183 const log = logging.getLoggerFromRoot('xtext.XtextService');
184 services.successListeners = [function(serviceType, result) {
185 if (log.getLevel() <= log.levels.TRACE) {
186 log.trace('service', serviceType, 'request success', JSON.parse(JSON.stringify(result)));
187 }
188 }];
189 services.errorListeners = [function(serviceType, severity, message, requestData) {
190 const messageParts = ['service', serviceType, 'failed:', message || '(no message)'];
191 if (requestData) {
192 messageParts.push(JSON.parse(JSON.stringify(requestData)));
193 }
194 if (severity === 'warning') {
195 log.warn(...messageParts);
196 } else {
197 log.error(...messageParts);
198 }
199 }];
200 }
201
202 /**
203 * Change the resource associated with this service builder.
204 */
205 ServiceBuilder.prototype.changeResource = function(resourceId) {
206 var services = this.services;
207 var options = services.options;
208 options.resourceId = resourceId;
209 for (var p in services) {
210 if (services.hasOwnProperty(p)) {
211 var service = services[p];
212 if (service._serviceType && jQuery.isFunction(service.initialize))
213 services[p].initialize(options.serviceUrl, service._serviceType, resourceId, services.updateService);
214 }
215 }
216 var knownServerState = services.editorContext.getServerState();
217 delete knownServerState.stateId;
218 delete knownServerState.text;
219 if (options.loadFromServer && jQuery.isFunction(services.loadResource)) {
220 services.loadResource();
221 }
222 }
223
224 /**
225 * Create a copy of the given object.
226 */
227 ServiceBuilder.copy = function(obj) {
228 var copy = {};
229 for (var p in obj) {
230 if (obj.hasOwnProperty(p))
231 copy[p] = obj[p];
232 }
233 return copy;
234 }
235
236 /**
237 * Translate an HTML attribute name to a JS option name.
238 */
239 ServiceBuilder.optionName = function(name) {
240 var prefix = 'data-editor-';
241 if (name.substring(0, prefix.length) === prefix) {
242 var key = name.substring(prefix.length);
243 key = key.replace(/-([a-z])/ig, function(all, character) {
244 return character.toUpperCase();
245 });
246 return key;
247 }
248 return undefined;
249 }
250
251 /**
252 * Copy all default options into the given set of additional options.
253 */
254 ServiceBuilder.mergeOptions = function(options, defaultOptions) {
255 if (options) {
256 for (var p in defaultOptions) {
257 if (defaultOptions.hasOwnProperty(p))
258 options[p] = defaultOptions[p];
259 }
260 return options;
261 } else {
262 return ServiceBuilder.copy(defaultOptions);
263 }
264 }
265
266 /**
267 * Merge all properties of the given parent element with the given default options.
268 */
269 ServiceBuilder.mergeParentOptions = function(parent, defaultOptions) {
270 var options = ServiceBuilder.copy(defaultOptions);
271 for (var attr, j = 0, attrs = parent.attributes, l = attrs.length; j < l; j++) {
272 attr = attrs.item(j);
273 var key = ServiceBuilder.optionName(attr.nodeName);
274 if (key) {
275 var value = attr.nodeValue;
276 if (value === 'true' || value === 'false')
277 value = value === 'true';
278 options[key] = value;
279 }
280 }
281 return options;
282 }
283
284 return ServiceBuilder;
285});
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts
new file mode 100644
index 00000000..9b672e79
--- /dev/null
+++ b/language-web/src/main/js/xtext/UpdateService.ts
@@ -0,0 +1,310 @@
1import {
2 ChangeDesc,
3 ChangeSet,
4 Transaction,
5} from '@codemirror/state';
6import { nanoid } from 'nanoid';
7
8import type { EditorStore } from '../editor/EditorStore';
9import type { XtextWebSocketClient } from './XtextWebSocketClient';
10import { ConditionVariable } from '../utils/ConditionVariable';
11import { getLogger } from '../utils/logger';
12import { Timer } from '../utils/Timer';
13import {
14 IContentAssistEntry,
15 isContentAssistResult,
16 isDocumentStateResult,
17 isInvalidStateIdConflictResult,
18} from './xtextServiceResults';
19
20const UPDATE_TIMEOUT_MS = 500;
21
22const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000;
23
24const log = getLogger('xtext.UpdateService');
25
26export interface IAbortSignal {
27 aborted: boolean;
28}
29
30export class UpdateService {
31 resourceName: string;
32
33 xtextStateId: string | null = null;
34
35 private readonly store: EditorStore;
36
37 /**
38 * The changes being synchronized to the server if a full or delta text update is running,
39 * `null` otherwise.
40 */
41 private pendingUpdate: ChangeDesc | null = null;
42
43 /**
44 * Local changes not yet sychronized to the server and not part of the running update, if any.
45 */
46 private dirtyChanges: ChangeDesc;
47
48 private readonly webSocketClient: XtextWebSocketClient;
49
50 private readonly updatedCondition = new ConditionVariable(
51 () => this.pendingUpdate === null && this.xtextStateId !== null,
52 WAIT_FOR_UPDATE_TIMEOUT_MS,
53 );
54
55 private readonly idleUpdateTimer = new Timer(() => {
56 this.handleIdleUpdate();
57 }, UPDATE_TIMEOUT_MS);
58
59 constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) {
60 this.resourceName = `${nanoid(7)}.problem`;
61 this.store = store;
62 this.dirtyChanges = this.newEmptyChangeDesc();
63 this.webSocketClient = webSocketClient;
64 }
65
66 onReconnect(): void {
67 this.xtextStateId = null;
68 this.updateFullText().catch((error) => {
69 log.error('Unexpected error during initial update', error);
70 });
71 }
72
73 onTransaction(transaction: Transaction): void {
74 if (transaction.docChanged) {
75 this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc);
76 this.idleUpdateTimer.reschedule();
77 }
78 }
79
80 /**
81 * Computes the summary of any changes happened since the last complete update.
82 *
83 * The result reflects any changes that happened since the `xtextStateId`
84 * version was uploaded to the server.
85 *
86 * @return the summary of changes since the last update
87 */
88 computeChangesSinceLastUpdate(): ChangeDesc {
89 return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges;
90 }
91
92 private handleIdleUpdate() {
93 if (!this.webSocketClient.isOpen || this.dirtyChanges.empty) {
94 return;
95 }
96 if (this.pendingUpdate === null) {
97 this.update().catch((error) => {
98 log.error('Unexpected error during scheduled update', error);
99 });
100 }
101 this.idleUpdateTimer.reschedule();
102 }
103
104 private newEmptyChangeDesc() {
105 const changeSet = ChangeSet.of([], this.store.state.doc.length);
106 return changeSet.desc;
107 }
108
109 async updateFullText(): Promise<void> {
110 await this.withUpdate(() => this.doUpdateFullText());
111 }
112
113 private async doUpdateFullText(): Promise<[string, void]> {
114 const result = await this.webSocketClient.send({
115 resource: this.resourceName,
116 serviceType: 'update',
117 fullText: this.store.state.doc.sliceString(0),
118 });
119 if (isDocumentStateResult(result)) {
120 return [result.stateId, undefined];
121 }
122 log.error('Unexpected full text update result:', result);
123 throw new Error('Full text update failed');
124 }
125
126 /**
127 * Makes sure that the document state on the server reflects recent
128 * local changes.
129 *
130 * Performs either an update with delta text or a full text update if needed.
131 * If there are not local dirty changes, the promise resolves immediately.
132 *
133 * @return a promise resolving when the update is completed
134 */
135 async update(): Promise<void> {
136 await this.prepareForDeltaUpdate();
137 const delta = this.computeDelta();
138 if (delta === null) {
139 return;
140 }
141 log.trace('Editor delta', delta);
142 await this.withUpdate(async () => {
143 const result = await this.webSocketClient.send({
144 resource: this.resourceName,
145 serviceType: 'update',
146 requiredStateId: this.xtextStateId,
147 ...delta,
148 });
149 if (isDocumentStateResult(result)) {
150 return [result.stateId, undefined];
151 }
152 if (isInvalidStateIdConflictResult(result)) {
153 return this.doFallbackToUpdateFullText();
154 }
155 log.error('Unexpected delta text update result:', result);
156 throw new Error('Delta text update failed');
157 });
158 }
159
160 private doFallbackToUpdateFullText() {
161 if (this.pendingUpdate === null) {
162 throw new Error('Only a pending update can be extended');
163 }
164 log.warn('Delta update failed, performing full text update');
165 this.xtextStateId = null;
166 this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges);
167 this.dirtyChanges = this.newEmptyChangeDesc();
168 return this.doUpdateFullText();
169 }
170
171 async fetchContentAssist(
172 params: Record<string, unknown>,
173 signal: IAbortSignal,
174 ): Promise<IContentAssistEntry[]> {
175 await this.prepareForDeltaUpdate();
176 if (signal.aborted) {
177 return [];
178 }
179 const delta = this.computeDelta();
180 if (delta !== null) {
181 log.trace('Editor delta', delta);
182 const entries = await this.withUpdate(async () => {
183 const result = await this.webSocketClient.send({
184 ...params,
185 requiredStateId: this.xtextStateId,
186 ...delta,
187 });
188 if (isContentAssistResult(result)) {
189 return [result.stateId, result.entries];
190 }
191 if (isInvalidStateIdConflictResult(result)) {
192 const [newStateId] = await this.doFallbackToUpdateFullText();
193 // We must finish this state update transaction to prepare for any push events
194 // before querying for content assist, so we just return `null` and will query
195 // the content assist service later.
196 return [newStateId, null];
197 }
198 log.error('Unextpected content assist result with delta update', result);
199 throw new Error('Unexpexted content assist result with delta update');
200 });
201 if (entries !== null) {
202 return entries;
203 }
204 if (signal.aborted) {
205 return [];
206 }
207 }
208 // Poscondition of `prepareForDeltaUpdate`: `xtextStateId !== null`
209 return this.doFetchContentAssist(params, this.xtextStateId as string);
210 }
211
212 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) {
213 const result = await this.webSocketClient.send({
214 ...params,
215 requiredStateId: expectedStateId,
216 });
217 if (isContentAssistResult(result) && result.stateId === expectedStateId) {
218 return result.entries;
219 }
220 log.error('Unexpected content assist result', result);
221 throw new Error('Unexpected content assist result');
222 }
223
224 private computeDelta() {
225 if (this.dirtyChanges.empty) {
226 return null;
227 }
228 let minFromA = Number.MAX_SAFE_INTEGER;
229 let maxToA = 0;
230 let minFromB = Number.MAX_SAFE_INTEGER;
231 let maxToB = 0;
232 this.dirtyChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
233 minFromA = Math.min(minFromA, fromA);
234 maxToA = Math.max(maxToA, toA);
235 minFromB = Math.min(minFromB, fromB);
236 maxToB = Math.max(maxToB, toB);
237 });
238 return {
239 deltaOffset: minFromA,
240 deltaReplaceLength: maxToA - minFromA,
241 deltaText: this.store.state.doc.sliceString(minFromB, maxToB),
242 };
243 }
244
245 /**
246 * Executes an asynchronous callback that updates the state on the server.
247 *
248 * Ensures that updates happen sequentially and manages `pendingUpdate`
249 * and `dirtyChanges` to reflect changes being synchronized to the server
250 * and not yet synchronized to the server, respectively.
251 *
252 * Optionally, `callback` may return a second value that is retured by this function.
253 *
254 * Once the remote procedure call to update the server state finishes
255 * and returns the new `stateId`, `callback` must return _immediately_
256 * to ensure that the local `stateId` is updated likewise to be able to handle
257 * push messages referring to the new `stateId` from the server.
258 * If additional work is needed to compute the second value in some cases,
259 * use `T | null` instead of `T` as a return type and signal the need for additional
260 * computations by returning `null`. Thus additional computations can be performed
261 * outside of the critical section.
262 *
263 * @param callback the asynchronous callback that updates the server state
264 * @return a promise resolving to the second value returned by `callback`
265 */
266 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> {
267 if (this.pendingUpdate !== null) {
268 throw new Error('Another update is pending, will not perform update');
269 }
270 this.pendingUpdate = this.dirtyChanges;
271 this.dirtyChanges = this.newEmptyChangeDesc();
272 let newStateId: string | null = null;
273 try {
274 let result: T;
275 [newStateId, result] = await callback();
276 this.xtextStateId = newStateId;
277 this.pendingUpdate = null;
278 this.updatedCondition.notifyAll();
279 return result;
280 } catch (e) {
281 log.error('Error while update', e);
282 if (this.pendingUpdate === null) {
283 log.error('pendingUpdate was cleared during update');
284 } else {
285 this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges);
286 }
287 this.pendingUpdate = null;
288 this.webSocketClient.forceReconnectOnError();
289 this.updatedCondition.rejectAll(e);
290 throw e;
291 }
292 }
293
294 /**
295 * Ensures that there is some state available on the server (`xtextStateId`)
296 * and that there is not pending update.
297 *
298 * After this function resolves, a delta text update is possible.
299 *
300 * @return a promise resolving when there is a valid state id but no pending update
301 */
302 private async prepareForDeltaUpdate() {
303 // If no update is pending, but the full text hasn't been uploaded to the server yet,
304 // we must start a full text upload.
305 if (this.pendingUpdate === null && this.xtextStateId === null) {
306 await this.updateFullText();
307 }
308 await this.updatedCondition.waitFor();
309 }
310}
diff --git a/language-web/src/main/js/xtext/ValidationService.ts b/language-web/src/main/js/xtext/ValidationService.ts
new file mode 100644
index 00000000..8e4934ac
--- /dev/null
+++ b/language-web/src/main/js/xtext/ValidationService.ts
@@ -0,0 +1,45 @@
1import type { Diagnostic } from '@codemirror/lint';
2
3import type { EditorStore } from '../editor/EditorStore';
4import type { UpdateService } from './UpdateService';
5import { getLogger } from '../utils/logger';
6import { isValidationResult } from './xtextServiceResults';
7
8const log = getLogger('xtext.ValidationService');
9
10export class ValidationService {
11 private readonly store: EditorStore;
12
13 private readonly updateService: UpdateService;
14
15 constructor(store: EditorStore, updateService: UpdateService) {
16 this.store = store;
17 this.updateService = updateService;
18 }
19
20 onPush(push: unknown): void {
21 if (!isValidationResult(push)) {
22 log.error('Invalid validation result', push);
23 return;
24 }
25 const allChanges = this.updateService.computeChangesSinceLastUpdate();
26 const diagnostics: Diagnostic[] = [];
27 push.issues.forEach(({
28 offset,
29 length,
30 severity,
31 description,
32 }) => {
33 if (severity === 'ignore') {
34 return;
35 }
36 diagnostics.push({
37 from: allChanges.mapPos(offset),
38 to: allChanges.mapPos(offset + length),
39 severity,
40 message: description,
41 });
42 });
43 this.store.updateDiagnostics(diagnostics);
44 }
45}
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts
new file mode 100644
index 00000000..28f3d0cc
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextClient.ts
@@ -0,0 +1,83 @@
1import type {
2 CompletionContext,
3 CompletionResult,
4} from '@codemirror/autocomplete';
5import type { Transaction } from '@codemirror/state';
6
7import type { EditorStore } from '../editor/EditorStore';
8import { ContentAssistService } from './ContentAssistService';
9import { HighlightingService } from './HighlightingService';
10import { OccurrencesService } from './OccurrencesService';
11import { UpdateService } from './UpdateService';
12import { getLogger } from '../utils/logger';
13import { ValidationService } from './ValidationService';
14import { XtextWebSocketClient } from './XtextWebSocketClient';
15
16const log = getLogger('xtext.XtextClient');
17
18export class XtextClient {
19 private readonly webSocketClient: XtextWebSocketClient;
20
21 private readonly updateService: UpdateService;
22
23 private readonly contentAssistService: ContentAssistService;
24
25 private readonly highlightingService: HighlightingService;
26
27 private readonly validationService: ValidationService;
28
29 private readonly occurrencesService: OccurrencesService;
30
31 constructor(store: EditorStore) {
32 this.webSocketClient = new XtextWebSocketClient(
33 () => this.updateService.onReconnect(),
34 (resource, stateId, service, push) => this.onPush(resource, stateId, service, push),
35 );
36 this.updateService = new UpdateService(store, this.webSocketClient);
37 this.contentAssistService = new ContentAssistService(this.updateService);
38 this.highlightingService = new HighlightingService(store, this.updateService);
39 this.validationService = new ValidationService(store, this.updateService);
40 this.occurrencesService = new OccurrencesService(
41 store,
42 this.webSocketClient,
43 this.updateService,
44 );
45 }
46
47 onTransaction(transaction: Transaction): void {
48 // `ContentAssistService.prototype.onTransaction` needs the dirty change desc
49 // _before_ the current edit, so we call it before `updateService`.
50 this.contentAssistService.onTransaction(transaction);
51 this.updateService.onTransaction(transaction);
52 this.occurrencesService.onTransaction(transaction);
53 }
54
55 private onPush(resource: string, stateId: string, service: string, push: unknown) {
56 const { resourceName, xtextStateId } = this.updateService;
57 if (resource !== resourceName) {
58 log.error('Unknown resource name: expected:', resourceName, 'got:', resource);
59 return;
60 }
61 if (stateId !== xtextStateId) {
62 log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId);
63 // The current push message might be stale (referring to a previous state),
64 // so this is not neccessarily an error and there is no need to force-reconnect.
65 return;
66 }
67 switch (service) {
68 case 'highlight':
69 this.highlightingService.onPush(push);
70 return;
71 case 'validate':
72 this.validationService.onPush(push);
73 return;
74 default:
75 log.error('Unknown push service:', service);
76 break;
77 }
78 }
79
80 contentAssist(context: CompletionContext): Promise<CompletionResult> {
81 return this.contentAssistService.contentAssist(context);
82 }
83}
diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
new file mode 100644
index 00000000..488e4b3b
--- /dev/null
+++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts
@@ -0,0 +1,341 @@
1import { nanoid } from 'nanoid';
2
3import { getLogger } from '../utils/logger';
4import { PendingTask } from '../utils/PendingTask';
5import { Timer } from '../utils/Timer';
6import {
7 isErrorResponse,
8 isOkResponse,
9 isPushMessage,
10 IXtextWebRequest,
11} from './xtextMessages';
12import { isPongResult } from './xtextServiceResults';
13
14const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
15
16const WEBSOCKET_CLOSE_OK = 1000;
17
18const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
19
20const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
21
22const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
23
24const PING_TIMEOUT_MS = 10 * 1000;
25
26const REQUEST_TIMEOUT_MS = 1000;
27
28const log = getLogger('xtext.XtextWebSocketClient');
29
30export type ReconnectHandler = () => void;
31
32export type PushHandler = (
33 resourceId: string,
34 stateId: string,
35 service: string,
36 data: unknown,
37) => void;
38
39enum State {
40 Initial,
41 Opening,
42 TabVisible,
43 TabHiddenIdle,
44 TabHiddenWaiting,
45 Error,
46 TimedOut,
47}
48
49export class XtextWebSocketClient {
50 private nextMessageId = 0;
51
52 private connection!: WebSocket;
53
54 private readonly pendingRequests = new Map<string, PendingTask<unknown>>();
55
56 private readonly onReconnect: ReconnectHandler;
57
58 private readonly onPush: PushHandler;
59
60 private state = State.Initial;
61
62 private reconnectTryCount = 0;
63
64 private readonly idleTimer = new Timer(() => {
65 this.handleIdleTimeout();
66 }, BACKGROUND_IDLE_TIMEOUT_MS);
67
68 private readonly pingTimer = new Timer(() => {
69 this.sendPing();
70 }, PING_TIMEOUT_MS);
71
72 private readonly reconnectTimer = new Timer(() => {
73 this.handleReconnect();
74 });
75
76 constructor(onReconnect: ReconnectHandler, onPush: PushHandler) {
77 this.onReconnect = onReconnect;
78 this.onPush = onPush;
79 document.addEventListener('visibilitychange', () => {
80 this.handleVisibilityChange();
81 });
82 this.reconnect();
83 }
84
85 private get isLogicallyClosed(): boolean {
86 return this.state === State.Error || this.state === State.TimedOut;
87 }
88
89 get isOpen(): boolean {
90 return this.state === State.TabVisible
91 || this.state === State.TabHiddenIdle
92 || this.state === State.TabHiddenWaiting;
93 }
94
95 private reconnect() {
96 if (this.isOpen || this.state === State.Opening) {
97 log.error('Trying to reconnect from', this.state);
98 return;
99 }
100 this.state = State.Opening;
101 const webSocketServer = window.origin.replace(/^http/, 'ws');
102 const webSocketUrl = `${webSocketServer}/xtext-service`;
103 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
104 this.connection.addEventListener('open', () => {
105 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
106 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server');
107 this.forceReconnectOnError();
108 }
109 if (document.visibilityState === 'hidden') {
110 this.handleTabHidden();
111 } else {
112 this.handleTabVisibleConnected();
113 }
114 log.info('Connected to websocket');
115 this.nextMessageId = 0;
116 this.reconnectTryCount = 0;
117 this.pingTimer.schedule();
118 this.onReconnect();
119 });
120 this.connection.addEventListener('error', (event) => {
121 log.error('Unexpected websocket error', event);
122 this.forceReconnectOnError();
123 });
124 this.connection.addEventListener('message', (event) => {
125 this.handleMessage(event.data);
126 });
127 this.connection.addEventListener('close', (event) => {
128 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK
129 && this.pendingRequests.size === 0) {
130 log.info('Websocket closed');
131 return;
132 }
133 log.error('Websocket closed unexpectedly', event.code, event.reason);
134 this.forceReconnectOnError();
135 });
136 }
137
138 private handleVisibilityChange() {
139 if (document.visibilityState === 'hidden') {
140 if (this.state === State.TabVisible) {
141 this.handleTabHidden();
142 }
143 return;
144 }
145 this.idleTimer.cancel();
146 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) {
147 this.handleTabVisibleConnected();
148 return;
149 }
150 if (this.state === State.TimedOut) {
151 this.reconnect();
152 }
153 }
154
155 private handleTabHidden() {
156 log.debug('Tab hidden while websocket is connected');
157 this.state = State.TabHiddenIdle;
158 this.idleTimer.schedule();
159 }
160
161 private handleTabVisibleConnected() {
162 log.debug('Tab visible while websocket is connected');
163 this.state = State.TabVisible;
164 }
165
166 private handleIdleTimeout() {
167 log.trace('Waiting for pending tasks before disconnect');
168 if (this.state === State.TabHiddenIdle) {
169 this.state = State.TabHiddenWaiting;
170 this.handleWaitingForDisconnect();
171 }
172 }
173
174 private handleWaitingForDisconnect() {
175 if (this.state !== State.TabHiddenWaiting) {
176 return;
177 }
178 const pending = this.pendingRequests.size;
179 if (pending === 0) {
180 log.info('Closing idle websocket');
181 this.state = State.TimedOut;
182 this.closeConnection(1000, 'idle timeout');
183 return;
184 }
185 log.info('Waiting for', pending, 'pending requests before closing websocket');
186 }
187
188 private sendPing() {
189 if (!this.isOpen) {
190 return;
191 }
192 const ping = nanoid();
193 log.trace('Ping', ping);
194 this.send({ ping }).then((result) => {
195 if (isPongResult(result) && result.pong === ping) {
196 log.trace('Pong', ping);
197 this.pingTimer.schedule();
198 } else {
199 log.error('Invalid pong');
200 this.forceReconnectOnError();
201 }
202 }).catch((error) => {
203 log.error('Error while waiting for ping', error);
204 this.forceReconnectOnError();
205 });
206 }
207
208 send(request: unknown): Promise<unknown> {
209 if (!this.isOpen) {
210 throw new Error('Not open');
211 }
212 const messageId = this.nextMessageId.toString(16);
213 if (messageId in this.pendingRequests) {
214 log.error('Message id wraparound still pending', messageId);
215 this.rejectRequest(messageId, new Error('Message id wraparound'));
216 }
217 if (this.nextMessageId >= Number.MAX_SAFE_INTEGER) {
218 this.nextMessageId = 0;
219 } else {
220 this.nextMessageId += 1;
221 }
222 const message = JSON.stringify({
223 id: messageId,
224 request,
225 } as IXtextWebRequest);
226 log.trace('Sending message', message);
227 return new Promise((resolve, reject) => {
228 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => {
229 this.removePendingRequest(messageId);
230 });
231 this.pendingRequests.set(messageId, task);
232 this.connection.send(message);
233 });
234 }
235
236 private handleMessage(messageStr: unknown) {
237 if (typeof messageStr !== 'string') {
238 log.error('Unexpected binary message', messageStr);
239 this.forceReconnectOnError();
240 return;
241 }
242 log.trace('Incoming websocket message', messageStr);
243 let message: unknown;
244 try {
245 message = JSON.parse(messageStr);
246 } catch (error) {
247 log.error('Json parse error', error);
248 this.forceReconnectOnError();
249 return;
250 }
251 if (isOkResponse(message)) {
252 this.resolveRequest(message.id, message.response);
253 } else if (isErrorResponse(message)) {
254 this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`));
255 if (message.error === 'server') {
256 log.error('Reconnecting due to server error: ', message.message);
257 this.forceReconnectOnError();
258 }
259 } else if (isPushMessage(message)) {
260 this.onPush(
261 message.resource,
262 message.stateId,
263 message.service,
264 message.push,
265 );
266 } else {
267 log.error('Unexpected websocket message', message);
268 this.forceReconnectOnError();
269 }
270 }
271
272 private resolveRequest(messageId: string, value: unknown) {
273 const pendingRequest = this.pendingRequests.get(messageId);
274 if (pendingRequest) {
275 pendingRequest.resolve(value);
276 this.removePendingRequest(messageId);
277 return;
278 }
279 log.error('Trying to resolve unknown request', messageId, 'with', value);
280 }
281
282 private rejectRequest(messageId: string, reason?: unknown) {
283 const pendingRequest = this.pendingRequests.get(messageId);
284 if (pendingRequest) {
285 pendingRequest.reject(reason);
286 this.removePendingRequest(messageId);
287 return;
288 }
289 log.error('Trying to reject unknown request', messageId, 'with', reason);
290 }
291
292 private removePendingRequest(messageId: string) {
293 this.pendingRequests.delete(messageId);
294 this.handleWaitingForDisconnect();
295 }
296
297 forceReconnectOnError(): void {
298 if (this.isLogicallyClosed) {
299 return;
300 }
301 this.abortPendingRequests();
302 this.closeConnection(1000, 'reconnecting due to error');
303 log.error('Reconnecting after delay due to error');
304 this.handleErrorState();
305 }
306
307 private abortPendingRequests() {
308 this.pendingRequests.forEach((request) => {
309 request.reject(new Error('Websocket disconnect'));
310 });
311 this.pendingRequests.clear();
312 }
313
314 private closeConnection(code: number, reason: string) {
315 this.pingTimer.cancel();
316 const { readyState } = this.connection;
317 if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
318 this.connection.close(code, reason);
319 }
320 }
321
322 private handleErrorState() {
323 this.state = State.Error;
324 this.reconnectTryCount += 1;
325 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
326 log.info('Reconnecting in', delay, 'ms');
327 this.reconnectTimer.schedule(delay);
328 }
329
330 private handleReconnect() {
331 if (this.state !== State.Error) {
332 log.error('Unexpected reconnect in', this.state);
333 return;
334 }
335 if (document.visibilityState === 'hidden') {
336 this.state = State.TimedOut;
337 } else {
338 this.reconnect();
339 }
340 }
341}
diff --git a/language-web/src/main/js/xtext/compatibility.js b/language-web/src/main/js/xtext/compatibility.js
deleted file mode 100644
index c877fc56..00000000
--- a/language-web/src/main/js/xtext/compatibility.js
+++ /dev/null
@@ -1,63 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define([], function() {
11
12 if (!Function.prototype.bind) {
13 Function.prototype.bind = function(target) {
14 if (typeof this !== 'function')
15 throw new TypeError('bind target is not callable');
16 var args = Array.prototype.slice.call(arguments, 1);
17 var unboundFunc = this;
18 var nopFunc = function() {};
19 boundFunc = function() {
20 var localArgs = Array.prototype.slice.call(arguments);
21 return unboundFunc.apply(this instanceof nopFunc ? this : target,
22 args.concat(localArgs));
23 };
24 nopFunc.prototype = this.prototype;
25 boundFunc.prototype = new nopFunc();
26 return boundFunc;
27 }
28 }
29
30 if (!Array.prototype.map) {
31 Array.prototype.map = function(callback, thisArg) {
32 if (this == null)
33 throw new TypeError('this is null');
34 if (typeof callback !== 'function')
35 throw new TypeError('callback is not callable');
36 var srcArray = Object(this);
37 var len = srcArray.length >>> 0;
38 var tgtArray = new Array(len);
39 for (var i = 0; i < len; i++) {
40 if (i in srcArray)
41 tgtArray[i] = callback.call(thisArg, srcArray[i], i, srcArray);
42 }
43 return tgtArray;
44 }
45 }
46
47 if (!Array.prototype.forEach) {
48 Array.prototype.forEach = function(callback, thisArg) {
49 if (this == null)
50 throw new TypeError('this is null');
51 if (typeof callback !== 'function')
52 throw new TypeError('callback is not callable');
53 var srcArray = Object(this);
54 var len = srcArray.length >>> 0;
55 for (var i = 0; i < len; i++) {
56 if (i in srcArray)
57 callback.call(thisArg, srcArray[i], i, srcArray);
58 }
59 }
60 }
61
62 return {};
63});
diff --git a/language-web/src/main/js/xtext/services/ContentAssistService.js b/language-web/src/main/js/xtext/services/ContentAssistService.js
deleted file mode 100644
index 1686570d..00000000
--- a/language-web/src/main/js/xtext/services/ContentAssistService.js
+++ /dev/null
@@ -1,132 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for content assist proposals. The proposals are returned as promise of
14 * a Deferred object.
15 */
16 function ContentAssistService(serviceUrl, resourceId, updateService) {
17 this.initialize(serviceUrl, 'assist', resourceId, updateService);
18 }
19
20 ContentAssistService.prototype = new XtextService();
21
22 ContentAssistService.prototype.invoke = function(editorContext, params, deferred) {
23 if (deferred === undefined) {
24 deferred = jQuery.Deferred();
25 }
26 var serverData = {
27 contentType: params.contentType
28 };
29 if (params.offset)
30 serverData.caretOffset = params.offset;
31 else
32 serverData.caretOffset = editorContext.getCaretOffset();
33 var selection = params.selection ? params.selection : editorContext.getSelection();
34 if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) {
35 serverData.selectionStart = selection.start;
36 serverData.selectionEnd = selection.end;
37 }
38 var currentText;
39 var httpMethod = 'GET';
40 var onComplete = undefined;
41 var knownServerState = editorContext.getServerState();
42 if (params.sendFullText) {
43 serverData.fullText = editorContext.getText();
44 httpMethod = 'POST';
45 } else {
46 serverData.requiredStateId = knownServerState.stateId;
47 if (this._updateService) {
48 if (knownServerState.text === undefined || knownServerState.updateInProgress) {
49 var self = this;
50 this._updateService.addCompletionCallback(function() {
51 self.invoke(editorContext, params, deferred);
52 });
53 return deferred.promise();
54 }
55 knownServerState.updateInProgress = true;
56 onComplete = this._updateService.onComplete.bind(this._updateService);
57 currentText = editorContext.getText();
58 this._updateService.computeDelta(knownServerState.text, currentText, serverData);
59 if (serverData.deltaText !== undefined) {
60 httpMethod = 'POST';
61 }
62 }
63 }
64
65 var self = this;
66 self.sendRequest(editorContext, {
67 type: httpMethod,
68 data: serverData,
69
70 success: function(result) {
71 if (result.conflict) {
72 // The server has lost its session state and the resource is loaded from the server
73 if (self._increaseRecursionCount(editorContext)) {
74 if (onComplete) {
75 delete knownServerState.updateInProgress;
76 delete knownServerState.text;
77 delete knownServerState.stateId;
78 self._updateService.addCompletionCallback(function() {
79 self.invoke(editorContext, params, deferred);
80 });
81 self._updateService.invoke(editorContext, params);
82 } else {
83 var paramsCopy = {};
84 for (var p in params) {
85 if (params.hasOwnProperty(p))
86 paramsCopy[p] = params[p];
87 }
88 paramsCopy.sendFullText = true;
89 self.invoke(editorContext, paramsCopy, deferred);
90 }
91 } else {
92 deferred.reject(result.conflict);
93 }
94 return false;
95 }
96 if (onComplete && result.stateId !== undefined && result.stateId != editorContext.getServerState().stateId) {
97 var listeners = editorContext.updateServerState(currentText, result.stateId);
98 for (var i = 0; i < listeners.length; i++) {
99 self._updateService.addCompletionCallback(listeners[i], params);
100 }
101 }
102 deferred.resolve(result.entries);
103 },
104
105 error: function(xhr, textStatus, errorThrown) {
106 if (onComplete && xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) {
107 // The server has lost its session state and the resource is not loaded from the server
108 delete knownServerState.updateInProgress;
109 delete knownServerState.text;
110 delete knownServerState.stateId;
111 self._updateService.addCompletionCallback(function() {
112 self.invoke(editorContext, params, deferred);
113 });
114 self._updateService.invoke(editorContext, params);
115 return true;
116 }
117 deferred.reject(errorThrown);
118 },
119
120 complete: onComplete
121 }, !params.sendFullText);
122 var result = deferred.promise();
123 if (onComplete) {
124 result.always(function() {
125 knownServerState.updateInProgress = false;
126 });
127 }
128 return result;
129 };
130
131 return ContentAssistService;
132});
diff --git a/language-web/src/main/js/xtext/services/FormattingService.js b/language-web/src/main/js/xtext/services/FormattingService.js
deleted file mode 100644
index f59099ee..00000000
--- a/language-web/src/main/js/xtext/services/FormattingService.js
+++ /dev/null
@@ -1,52 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for formatting text.
14 */
15 function FormattingService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'format', resourceId, updateService);
17 };
18
19 FormattingService.prototype = new XtextService();
20
21 FormattingService.prototype._initServerData = function(serverData, editorContext, params) {
22 var selection = params.selection ? params.selection : editorContext.getSelection();
23 if (selection.end > selection.start) {
24 serverData.selectionStart = selection.start;
25 serverData.selectionEnd = selection.end;
26 }
27 return {
28 httpMethod: 'POST'
29 };
30 };
31
32 FormattingService.prototype._processResult = function(result, editorContext) {
33 // The text update may be asynchronous, so we have to compute the new text ourselves
34 var newText;
35 if (result.replaceRegion) {
36 var fullText = editorContext.getText();
37 var start = result.replaceRegion.offset;
38 var end = result.replaceRegion.offset + result.replaceRegion.length;
39 editorContext.setText(result.formattedText, start, end);
40 newText = fullText.substring(0, start) + result.formattedText + fullText.substring(end);
41 } else {
42 editorContext.setText(result.formattedText);
43 newText = result.formattedText;
44 }
45 var listeners = editorContext.updateServerState(newText, result.stateId);
46 for (var i = 0; i < listeners.length; i++) {
47 listeners[i]({});
48 }
49 };
50
51 return FormattingService;
52}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/HighlightingService.js b/language-web/src/main/js/xtext/services/HighlightingService.js
deleted file mode 100644
index 5a5ac8ba..00000000
--- a/language-web/src/main/js/xtext/services/HighlightingService.js
+++ /dev/null
@@ -1,33 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for semantic highlighting.
14 */
15 function HighlightingService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'highlight', resourceId);
17 };
18
19 HighlightingService.prototype = new XtextService();
20
21 HighlightingService.prototype._checkPreconditions = function(editorContext, params) {
22 return this._state === undefined;
23 }
24
25 HighlightingService.prototype._onConflict = function(editorContext, cause) {
26 this.setState(undefined);
27 return {
28 suppressForcedUpdate: true
29 };
30 };
31
32 return HighlightingService;
33}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/HoverService.js b/language-web/src/main/js/xtext/services/HoverService.js
deleted file mode 100644
index 03c5a52b..00000000
--- a/language-web/src/main/js/xtext/services/HoverService.js
+++ /dev/null
@@ -1,59 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for hover information.
14 */
15 function HoverService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'hover', resourceId, updateService);
17 };
18
19 HoverService.prototype = new XtextService();
20
21 HoverService.prototype._initServerData = function(serverData, editorContext, params) {
22 // In order to display hover info for a selected completion proposal while the content
23 // assist popup is shown, the selected proposal is passed as parameter
24 if (params.proposal && params.proposal.proposal)
25 serverData.proposal = params.proposal.proposal;
26 if (params.offset)
27 serverData.caretOffset = params.offset;
28 else
29 serverData.caretOffset = editorContext.getCaretOffset();
30 var selection = params.selection ? params.selection : editorContext.getSelection();
31 if (selection.start != serverData.caretOffset || selection.end != serverData.caretOffset) {
32 serverData.selectionStart = selection.start;
33 serverData.selectionEnd = selection.end;
34 }
35 };
36
37 HoverService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
38 var delay = params.mouseHoverDelay;
39 if (!delay)
40 delay = 500;
41 var showTime = new Date().getTime() + delay;
42 return function(result) {
43 if (result.conflict || !result.title && !result.content) {
44 deferred.reject();
45 } else {
46 var remainingTimeout = Math.max(0, showTime - new Date().getTime());
47 setTimeout(function() {
48 if (!params.sendFullText && result.stateId !== undefined
49 && result.stateId != editorContext.getServerState().stateId)
50 deferred.reject();
51 else
52 deferred.resolve(result);
53 }, remainingTimeout);
54 }
55 };
56 };
57
58 return HoverService;
59}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/LoadResourceService.js b/language-web/src/main/js/xtext/services/LoadResourceService.js
deleted file mode 100644
index b5a315c3..00000000
--- a/language-web/src/main/js/xtext/services/LoadResourceService.js
+++ /dev/null
@@ -1,42 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for loading resources. The resulting text is passed to the editor context.
14 */
15 function LoadResourceService(serviceUrl, resourceId, revert) {
16 this.initialize(serviceUrl, revert ? 'revert' : 'load', resourceId);
17 };
18
19 LoadResourceService.prototype = new XtextService();
20
21 LoadResourceService.prototype._initServerData = function(serverData, editorContext, params) {
22 return {
23 suppressContent: true,
24 httpMethod: this._serviceType == 'revert' ? 'POST' : 'GET'
25 };
26 };
27
28 LoadResourceService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
29 return function(result) {
30 editorContext.setText(result.fullText);
31 editorContext.clearUndoStack();
32 editorContext.setDirty(result.dirty);
33 var listeners = editorContext.updateServerState(result.fullText, result.stateId);
34 for (var i = 0; i < listeners.length; i++) {
35 listeners[i](params);
36 }
37 deferred.resolve(result);
38 }
39 }
40
41 return LoadResourceService;
42}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/OccurrencesService.js b/language-web/src/main/js/xtext/services/OccurrencesService.js
deleted file mode 100644
index 2e2d0b1a..00000000
--- a/language-web/src/main/js/xtext/services/OccurrencesService.js
+++ /dev/null
@@ -1,39 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for marking occurrences.
14 */
15 function OccurrencesService(serviceUrl, resourceId, updateService) {
16 this.initialize(serviceUrl, 'occurrences', resourceId, updateService);
17 };
18
19 OccurrencesService.prototype = new XtextService();
20
21 OccurrencesService.prototype._initServerData = function(serverData, editorContext, params) {
22 if (params.offset)
23 serverData.caretOffset = params.offset;
24 else
25 serverData.caretOffset = editorContext.getCaretOffset();
26 };
27
28 OccurrencesService.prototype._getSuccessCallback = function(editorContext, params, deferred) {
29 return function(result) {
30 if (result.conflict || !params.sendFullText && result.stateId !== undefined
31 && result.stateId != editorContext.getServerState().stateId)
32 deferred.reject();
33 else
34 deferred.resolve(result);
35 }
36 }
37
38 return OccurrencesService;
39}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/SaveResourceService.js b/language-web/src/main/js/xtext/services/SaveResourceService.js
deleted file mode 100644
index 66cdaff5..00000000
--- a/language-web/src/main/js/xtext/services/SaveResourceService.js
+++ /dev/null
@@ -1,32 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for saving resources.
14 */
15 function SaveResourceService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'save', resourceId);
17 };
18
19 SaveResourceService.prototype = new XtextService();
20
21 SaveResourceService.prototype._initServerData = function(serverData, editorContext, params) {
22 return {
23 httpMethod: 'POST'
24 };
25 };
26
27 SaveResourceService.prototype._processResult = function(result, editorContext) {
28 editorContext.setDirty(false);
29 };
30
31 return SaveResourceService;
32}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/UpdateService.js b/language-web/src/main/js/xtext/services/UpdateService.js
deleted file mode 100644
index b78d846d..00000000
--- a/language-web/src/main/js/xtext/services/UpdateService.js
+++ /dev/null
@@ -1,159 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for updating the server-side representation of a resource.
14 * This service only makes sense with a stateful server, where an update request is sent
15 * after each modification. This can greatly improve response times compared to the
16 * stateless alternative, where the full text content is sent with each service request.
17 */
18 function UpdateService(serviceUrl, resourceId) {
19 this.initialize(serviceUrl, 'update', resourceId, this);
20 this._completionCallbacks = [];
21 };
22
23 UpdateService.prototype = new XtextService();
24
25 /**
26 * Compute a delta between two versions of a text. If a difference is found, the result
27 * contains three properties:
28 * deltaText - the text to insert into s1
29 * deltaOffset - the text insertion offset
30 * deltaReplaceLength - the number of characters that shall be replaced by the inserted text
31 */
32 UpdateService.prototype.computeDelta = function(s1, s2, result) {
33 var start = 0, s1length = s1.length, s2length = s2.length;
34 while (start < s1length && start < s2length && s1.charCodeAt(start) === s2.charCodeAt(start)) {
35 start++;
36 }
37 if (start === s1length && start === s2length) {
38 return;
39 }
40 result.deltaOffset = start;
41 if (start === s1length) {
42 result.deltaText = s2.substring(start, s2length);
43 result.deltaReplaceLength = 0;
44 return;
45 } else if (start === s2length) {
46 result.deltaText = '';
47 result.deltaReplaceLength = s1length - start;
48 return;
49 }
50
51 var end1 = s1length - 1, end2 = s2length - 1;
52 while (end1 >= start && end2 >= start && s1.charCodeAt(end1) === s2.charCodeAt(end2)) {
53 end1--;
54 end2--;
55 }
56 result.deltaText = s2.substring(start, end2 + 1);
57 result.deltaReplaceLength = end1 - start + 1;
58 };
59
60 /**
61 * Invoke all completion callbacks and clear the list afterwards.
62 */
63 UpdateService.prototype.onComplete = function(xhr, textStatus) {
64 var callbacks = this._completionCallbacks;
65 this._completionCallbacks = [];
66 for (var i = 0; i < callbacks.length; i++) {
67 var callback = callbacks[i].callback;
68 var params = callbacks[i].params;
69 callback(params);
70 }
71 }
72
73 /**
74 * Add a callback to be invoked when the service call has completed.
75 */
76 UpdateService.prototype.addCompletionCallback = function(callback, params) {
77 this._completionCallbacks.push({callback: callback, params: params});
78 }
79
80 UpdateService.prototype.invoke = function(editorContext, params, deferred) {
81 if (deferred === undefined) {
82 deferred = jQuery.Deferred();
83 }
84 var knownServerState = editorContext.getServerState();
85 if (knownServerState.updateInProgress) {
86 var self = this;
87 this.addCompletionCallback(function() { self.invoke(editorContext, params, deferred) });
88 return deferred.promise();
89 }
90
91 var serverData = {
92 contentType: params.contentType
93 };
94 var currentText = editorContext.getText();
95 if (params.sendFullText || knownServerState.text === undefined) {
96 serverData.fullText = currentText;
97 } else {
98 this.computeDelta(knownServerState.text, currentText, serverData);
99 if (serverData.deltaText === undefined) {
100 if (params.forceUpdate) {
101 serverData.deltaText = '';
102 serverData.deltaOffset = editorContext.getCaretOffset();
103 serverData.deltaReplaceLength = 0;
104 } else {
105 deferred.resolve(knownServerState);
106 this.onComplete();
107 return deferred.promise();
108 }
109 }
110 serverData.requiredStateId = knownServerState.stateId;
111 }
112
113 knownServerState.updateInProgress = true;
114 var self = this;
115 self.sendRequest(editorContext, {
116 type: 'PUT',
117 data: serverData,
118
119 success: function(result) {
120 if (result.conflict) {
121 // The server has lost its session state and the resource is loaded from the server
122 if (knownServerState.text !== undefined) {
123 delete knownServerState.updateInProgress;
124 delete knownServerState.text;
125 delete knownServerState.stateId;
126 self.invoke(editorContext, params, deferred);
127 } else {
128 deferred.reject(result.conflict);
129 }
130 return false;
131 }
132 var listeners = editorContext.updateServerState(currentText, result.stateId);
133 for (var i = 0; i < listeners.length; i++) {
134 self.addCompletionCallback(listeners[i], params);
135 }
136 deferred.resolve(result);
137 },
138
139 error: function(xhr, textStatus, errorThrown) {
140 if (xhr.status == 404 && !params.loadFromServer && knownServerState.text !== undefined) {
141 // The server has lost its session state and the resource is not loaded from the server
142 delete knownServerState.updateInProgress;
143 delete knownServerState.text;
144 delete knownServerState.stateId;
145 self.invoke(editorContext, params, deferred);
146 return true;
147 }
148 deferred.reject(errorThrown);
149 },
150
151 complete: self.onComplete.bind(self)
152 }, true);
153 return deferred.promise().always(function() {
154 knownServerState.updateInProgress = false;
155 });
156 };
157
158 return UpdateService;
159}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/ValidationService.js b/language-web/src/main/js/xtext/services/ValidationService.js
deleted file mode 100644
index 85c9953d..00000000
--- a/language-web/src/main/js/xtext/services/ValidationService.js
+++ /dev/null
@@ -1,33 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['xtext/services/XtextService', 'jquery'], function(XtextService, jQuery) {
11
12 /**
13 * Service class for validation.
14 */
15 function ValidationService(serviceUrl, resourceId) {
16 this.initialize(serviceUrl, 'validate', resourceId);
17 };
18
19 ValidationService.prototype = new XtextService();
20
21 ValidationService.prototype._checkPreconditions = function(editorContext, params) {
22 return this._state === undefined;
23 }
24
25 ValidationService.prototype._onConflict = function(editorContext, cause) {
26 this.setState(undefined);
27 return {
28 suppressForcedUpdate: true
29 };
30 };
31
32 return ValidationService;
33}); \ No newline at end of file
diff --git a/language-web/src/main/js/xtext/services/XtextService.js b/language-web/src/main/js/xtext/services/XtextService.js
deleted file mode 100644
index d3a4842f..00000000
--- a/language-web/src/main/js/xtext/services/XtextService.js
+++ /dev/null
@@ -1,280 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10define(['jquery'], function(jQuery) {
11
12 var globalState = {};
13
14 /**
15 * Generic service implementation that can serve as superclass for specialized services.
16 */
17 function XtextService() {};
18
19 /**
20 * Initialize the request metadata for this service class. Two variants:
21 * - initialize(serviceUrl, serviceType, resourceId, updateService)
22 * - initialize(xtextServices, serviceType)
23 */
24 XtextService.prototype.initialize = function() {
25 this._serviceType = arguments[1];
26 if (typeof(arguments[0]) === 'string') {
27 this._requestUrl = arguments[0] + '/' + this._serviceType;
28 var resourceId = arguments[2];
29 if (resourceId)
30 this._encodedResourceId = encodeURIComponent(resourceId);
31 this._updateService = arguments[3];
32 } else {
33 var xtextServices = arguments[0];
34 if (xtextServices.options) {
35 this._requestUrl = xtextServices.options.serviceUrl + '/' + this._serviceType;
36 var resourceId = xtextServices.options.resourceId;
37 if (resourceId)
38 this._encodedResourceId = encodeURIComponent(resourceId);
39 }
40 this._updateService = xtextServices.updateService;
41 }
42 }
43
44 XtextService.prototype.setState = function(state) {
45 this._state = state;
46 }
47
48 /**
49 * Invoke the service with default service behavior.
50 */
51 XtextService.prototype.invoke = function(editorContext, params, deferred, callbacks) {
52 if (deferred === undefined) {
53 deferred = jQuery.Deferred();
54 }
55 if (jQuery.isFunction(this._checkPreconditions) && !this._checkPreconditions(editorContext, params)) {
56 deferred.reject();
57 return deferred.promise();
58 }
59 var serverData = {
60 contentType: params.contentType
61 };
62 var initResult;
63 if (jQuery.isFunction(this._initServerData))
64 initResult = this._initServerData(serverData, editorContext, params);
65 var httpMethod = 'GET';
66 if (initResult && initResult.httpMethod)
67 httpMethod = initResult.httpMethod;
68 var self = this;
69 if (!(initResult && initResult.suppressContent)) {
70 if (params.sendFullText) {
71 serverData.fullText = editorContext.getText();
72 httpMethod = 'POST';
73 } else {
74 var knownServerState = editorContext.getServerState();
75 if (knownServerState.updateInProgress) {
76 if (self._updateService) {
77 self._updateService.addCompletionCallback(function() {
78 self.invoke(editorContext, params, deferred);
79 });
80 } else {
81 deferred.reject();
82 }
83 return deferred.promise();
84 }
85 if (knownServerState.stateId !== undefined) {
86 serverData.requiredStateId = knownServerState.stateId;
87 }
88 }
89 }
90
91 var onSuccess;
92 if (jQuery.isFunction(this._getSuccessCallback)) {
93 onSuccess = this._getSuccessCallback(editorContext, params, deferred);
94 } else {
95 onSuccess = function(result) {
96 if (result.conflict) {
97 if (self._increaseRecursionCount(editorContext)) {
98 var onConflictResult;
99 if (jQuery.isFunction(self._onConflict)) {
100 onConflictResult = self._onConflict(editorContext, result.conflict);
101 }
102 if (!(onConflictResult && onConflictResult.suppressForcedUpdate) && !params.sendFullText
103 && result.conflict == 'invalidStateId' && self._updateService) {
104 self._updateService.addCompletionCallback(function() {
105 self.invoke(editorContext, params, deferred);
106 });
107 var knownServerState = editorContext.getServerState();
108 delete knownServerState.stateId;
109 delete knownServerState.text;
110 self._updateService.invoke(editorContext, params);
111 } else {
112 self.invoke(editorContext, params, deferred);
113 }
114 } else {
115 deferred.reject();
116 }
117 return false;
118 }
119 if (jQuery.isFunction(self._processResult)) {
120 var processedResult = self._processResult(result, editorContext);
121 if (processedResult) {
122 deferred.resolve(processedResult);
123 return true;
124 }
125 }
126 deferred.resolve(result);
127 };
128 }
129
130 var onError = function(xhr, textStatus, errorThrown) {
131 if (xhr.status == 404 && !params.loadFromServer && self._increaseRecursionCount(editorContext)) {
132 var onConflictResult;
133 if (jQuery.isFunction(self._onConflict)) {
134 onConflictResult = self._onConflict(editorContext, errorThrown);
135 }
136 var knownServerState = editorContext.getServerState();
137 if (!(onConflictResult && onConflictResult.suppressForcedUpdate)
138 && knownServerState.text !== undefined && self._updateService) {
139 self._updateService.addCompletionCallback(function() {
140 self.invoke(editorContext, params, deferred);
141 });
142 delete knownServerState.stateId;
143 delete knownServerState.text;
144 self._updateService.invoke(editorContext, params);
145 return true;
146 }
147 }
148 deferred.reject(errorThrown);
149 }
150
151 self.sendRequest(editorContext, {
152 type: httpMethod,
153 data: serverData,
154 success: onSuccess,
155 error: onError
156 }, !params.sendFullText);
157 return deferred.promise().always(function() {
158 self._recursionCount = undefined;
159 });
160 }
161
162 /**
163 * Send an HTTP request to invoke the service.
164 */
165 XtextService.prototype.sendRequest = function(editorContext, settings, needsSession) {
166 var self = this;
167 self.setState('started');
168 var corsEnabled = editorContext.xtextServices.options['enableCors'];
169 if(corsEnabled) {
170 settings.crossDomain = true;
171 settings.xhrFields = {withCredentials: true};
172 }
173 var onSuccess = settings.success;
174 settings.success = function(result) {
175 var accepted = true;
176 if (jQuery.isFunction(onSuccess)) {
177 accepted = onSuccess(result);
178 }
179 if (accepted || accepted === undefined) {
180 self.setState('finished');
181 if (editorContext.xtextServices) {
182 var successListeners = editorContext.xtextServices.successListeners;
183 if (successListeners) {
184 for (var i = 0; i < successListeners.length; i++) {
185 var listener = successListeners[i];
186 if (jQuery.isFunction(listener)) {
187 listener(self._serviceType, result);
188 }
189 }
190 }
191 }
192 }
193 };
194
195 var onError = settings.error;
196 settings.error = function(xhr, textStatus, errorThrown) {
197 var resolved = false;
198 if (jQuery.isFunction(onError)) {
199 resolved = onError(xhr, textStatus, errorThrown);
200 }
201 if (!resolved) {
202 self.setState(undefined);
203 self._reportError(editorContext, textStatus, errorThrown, xhr);
204 }
205 };
206
207 settings.async = true;
208 var requestUrl = self._requestUrl;
209 if (!settings.data.resource && self._encodedResourceId) {
210 if (requestUrl.indexOf('?') >= 0)
211 requestUrl += '&resource=' + self._encodedResourceId;
212 else
213 requestUrl += '?resource=' + self._encodedResourceId;
214 }
215
216 if (needsSession && globalState._initPending) {
217 // We have to wait until the initial request has finished to make sure the client has
218 // received a valid session id
219 if (!globalState._waitingRequests)
220 globalState._waitingRequests = [];
221 globalState._waitingRequests.push({requestUrl: requestUrl, settings: settings});
222 } else {
223 if (needsSession && !globalState._initDone) {
224 globalState._initPending = true;
225 var onComplete = settings.complete;
226 settings.complete = function(xhr, textStatus) {
227 if (jQuery.isFunction(onComplete)) {
228 onComplete(xhr, textStatus);
229 }
230 delete globalState._initPending;
231 globalState._initDone = true;
232 if (globalState._waitingRequests) {
233 for (var i = 0; i < globalState._waitingRequests.length; i++) {
234 var request = globalState._waitingRequests[i];
235 jQuery.ajax(request.requestUrl, request.settings);
236 }
237 delete globalState._waitingRequests;
238 }
239 }
240 }
241 jQuery.ajax(requestUrl, settings);
242 }
243 }
244
245 /**
246 * Use this in case of a conflict before retrying the service invocation. If the number
247 * of retries exceeds the limit, an error is reported and the function returns false.
248 */
249 XtextService.prototype._increaseRecursionCount = function(editorContext) {
250 if (this._recursionCount === undefined)
251 this._recursionCount = 1;
252 else
253 this._recursionCount++;
254
255 if (this._recursionCount >= 10) {
256 this._reportError(editorContext, 'warning', 'Xtext service request failed after 10 attempts.', {});
257 return false;
258 }
259 return true;
260 },
261
262 /**
263 * Report an error to the listeners.
264 */
265 XtextService.prototype._reportError = function(editorContext, severity, message, requestData) {
266 if (editorContext.xtextServices) {
267 var errorListeners = editorContext.xtextServices.errorListeners;
268 if (errorListeners) {
269 for (var i = 0; i < errorListeners.length; i++) {
270 var listener = errorListeners[i];
271 if (jQuery.isFunction(listener)) {
272 listener(this._serviceType, severity, message, requestData);
273 }
274 }
275 }
276 }
277 }
278
279 return XtextService;
280});
diff --git a/language-web/src/main/js/xtext/xtext-codemirror.d.ts b/language-web/src/main/js/xtext/xtext-codemirror.d.ts
deleted file mode 100644
index fff850b8..00000000
--- a/language-web/src/main/js/xtext/xtext-codemirror.d.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Editor } from 'codemirror';
2
3export function createEditor(options: IXtextOptions): IXtextCodeMirrorEditor;
4
5export function createServices(editor: Editor, options: IXtextOptions): IXtextServices;
6
7export function removeServices(editor: Editor): void;
8
9export interface IXtextOptions {
10 baseUrl?: string;
11 contentType?: string;
12 dirtyElement?: string | Element;
13 dirtyStatusClass?: string;
14 document?: Document;
15 enableContentAssistService?: boolean;
16 enableCors?: boolean;
17 enableFormattingAction?: boolean;
18 enableFormattingService?: boolean;
19 enableGeneratorService?: boolean;
20 enableHighlightingService?: boolean;
21 enableOccurrencesService?: boolean;
22 enableSaveAction?: boolean;
23 enableValidationService?: boolean;
24 loadFromServer?: boolean;
25 mode?: string;
26 parent?: string | Element;
27 parentClass?: string;
28 resourceId?: string;
29 selectionUpdateDelay?: number;
30 sendFullText?: boolean;
31 serviceUrl?: string;
32 showErrorDialogs?: boolean;
33 syntaxDefinition?: string;
34 textUpdateDelay?: number;
35 xtextLang?: string;
36}
37
38export interface IXtextCodeMirrorEditor extends Editor {
39 xtextServices: IXtextServices;
40}
41
42export interface IXtextServices {
43}
diff --git a/language-web/src/main/js/xtext/xtext-codemirror.js b/language-web/src/main/js/xtext/xtext-codemirror.js
deleted file mode 100644
index d246172a..00000000
--- a/language-web/src/main/js/xtext/xtext-codemirror.js
+++ /dev/null
@@ -1,473 +0,0 @@
1/*******************************************************************************
2 * Copyright (c) 2015, 2017 itemis AG (http://www.itemis.eu) and others.
3 * This program and the accompanying materials are made available under the
4 * terms of the Eclipse Public License 2.0 which is available at
5 * http://www.eclipse.org/legal/epl-2.0.
6 *
7 * SPDX-License-Identifier: EPL-2.0
8 *******************************************************************************/
9
10/*
11 * Use `createEditor(options)` to create an Xtext editor. You can specify options either
12 * through the function parameter or through `data-editor-x` attributes, where x is an
13 * option name with camelCase converted to hyphen-separated.
14 * In addition to the options supported by CodeMirror (https://codemirror.net/doc/manual.html#config),
15 * the following options are available:
16 *
17 * baseUrl = "/" {String}
18 * The path segment where the Xtext service is found; see serviceUrl option.
19 * contentType {String}
20 * The content type included in requests to the Xtext server.
21 * dirtyElement {String | DOMElement}
22 * An element into which the dirty status class is written when the editor is marked dirty;
23 * it can be either a DOM element or an ID for a DOM element.
24 * dirtyStatusClass = 'dirty' {String}
25 * A CSS class name written into the dirtyElement when the editor is marked dirty.
26 * document {Document}
27 * The document; if not specified, the global document is used.
28 * enableContentAssistService = true {Boolean}
29 * Whether content assist should be enabled.
30 * enableCors = true {Boolean}
31 * Whether CORS should be enabled for service request.
32 * enableFormattingAction = false {Boolean}
33 * Whether the formatting action should be bound to the standard keystroke ctrl+shift+s / cmd+shift+f.
34 * enableFormattingService = true {Boolean}
35 * Whether text formatting should be enabled.
36 * enableGeneratorService = true {Boolean}
37 * Whether code generation should be enabled (must be triggered through JavaScript code).
38 * enableHighlightingService = true {Boolean}
39 * Whether semantic highlighting (computed on the server) should be enabled.
40 * enableOccurrencesService = true {Boolean}
41 * Whether marking occurrences should be enabled.
42 * enableSaveAction = false {Boolean}
43 * Whether the save action should be bound to the standard keystroke ctrl+s / cmd+s.
44 * enableValidationService = true {Boolean}
45 * Whether validation should be enabled.
46 * loadFromServer = true {Boolean}
47 * Whether to load the editor content from the server.
48 * mode {String}
49 * The name of the syntax highlighting mode to use; the mode has to be registered externally
50 * (see CodeMirror documentation).
51 * parent = 'xtext-editor' {String | DOMElement}
52 * The parent element for the view; it can be either a DOM element or an ID for a DOM element.
53 * parentClass = 'xtext-editor' {String}
54 * If the 'parent' option is not given, this option is used to find elements that match the given class name.
55 * resourceId {String}
56 * The identifier of the resource displayed in the text editor; this option is sent to the server to
57 * communicate required information on the respective resource.
58 * selectionUpdateDelay = 550 {Number}
59 * The number of milliseconds to wait after a selection change before Xtext services are invoked.
60 * sendFullText = false {Boolean}
61 * Whether the full text shall be sent to the server with each request; use this if you want
62 * the server to run in stateless mode. If the option is inactive, the server state is updated regularly.
63 * serviceUrl {String}
64 * The URL of the Xtext servlet; if no value is given, it is constructed using the baseUrl option in the form
65 * {location.protocol}//{location.host}{baseUrl}xtext-service
66 * showErrorDialogs = false {Boolean}
67 * Whether errors should be displayed in popup dialogs.
68 * syntaxDefinition {String}
69 * If the 'mode' option is not set, the default mode 'xtext/{xtextLang}' is used. Set this option to
70 * 'none' to suppress this behavior and disable syntax highlighting.
71 * textUpdateDelay = 500 {Number}
72 * The number of milliseconds to wait after a text change before Xtext services are invoked.
73 * xtextLang {String}
74 * The language name (usually the file extension configured for the language).
75 */
76define([
77 'jquery',
78 'codemirror',
79 'codemirror/addon/hint/show-hint',
80 'xtext/compatibility',
81 'xtext/ServiceBuilder',
82 'xtext/CodeMirrorEditorContext',
83 'codemirror/mode/javascript/javascript'
84], function(jQuery, CodeMirror, ShowHint, compatibility, ServiceBuilder, EditorContext) {
85
86 var exports = {};
87
88 /**
89 * Create one or more Xtext editor instances configured with the given options.
90 * The return value is either a CodeMirror editor or an array of CodeMirror editors.
91 */
92 exports.createEditor = function(options) {
93 if (!options)
94 options = {};
95
96 var query;
97 if (jQuery.type(options.parent) === 'string') {
98 query = jQuery('#' + options.parent, options.document);
99 } else if (options.parent) {
100 query = jQuery(options.parent);
101 } else if (jQuery.type(options.parentClass) === 'string') {
102 query = jQuery('.' + options.parentClass, options.document);
103 } else {
104 query = jQuery('#xtext-editor', options.document);
105 if (query.length == 0)
106 query = jQuery('.xtext-editor', options.document);
107 }
108
109 var editors = [];
110 query.each(function(index, parent) {
111 var editorOptions = ServiceBuilder.mergeParentOptions(parent, options);
112 if (!editorOptions.value)
113 editorOptions.value = jQuery(parent).text();
114 var editor = CodeMirror(function(element) {
115 jQuery(parent).empty().append(element);
116 }, editorOptions);
117
118 exports.createServices(editor, editorOptions);
119 editors[index] = editor;
120 });
121
122 if (editors.length == 1)
123 return editors[0];
124 else
125 return editors;
126 }
127
128 function CodeMirrorServiceBuilder(editor, xtextServices) {
129 this.editor = editor;
130 xtextServices.editorContext._highlightingMarkers = [];
131 xtextServices.editorContext._validationMarkers = [];
132 xtextServices.editorContext._occurrenceMarkers = [];
133 ServiceBuilder.call(this, xtextServices);
134 }
135 CodeMirrorServiceBuilder.prototype = new ServiceBuilder();
136
137 /**
138 * Configure Xtext services for the given editor. The editor does not have to be created
139 * with createEditor(options).
140 */
141 exports.createServices = function(editor, options) {
142 if (options.enableValidationService || options.enableValidationService === undefined) {
143 editor.setOption('gutters', ['annotations-gutter']);
144 }
145 var xtextServices = {
146 options: options,
147 editorContext: new EditorContext(editor)
148 };
149 var serviceBuilder = new CodeMirrorServiceBuilder(editor, xtextServices);
150 serviceBuilder.createServices();
151 xtextServices.serviceBuilder = serviceBuilder;
152 editor.xtextServices = xtextServices;
153 return xtextServices;
154 }
155
156 /**
157 * Remove all services and listeners that have been previously created with createServices(editor, options).
158 */
159 exports.removeServices = function(editor) {
160 if (!editor.xtextServices)
161 return;
162 var services = editor.xtextServices;
163 if (services.modelChangeListener)
164 editor.off('changes', services.modelChangeListener);
165 if (services.cursorActivityListener)
166 editor.off('cursorActivity', services.cursorActivityListener);
167 if (services.saveKeyMap)
168 editor.removeKeyMap(services.saveKeyMap);
169 if (services.contentAssistKeyMap)
170 editor.removeKeyMap(services.contentAssistKeyMap);
171 if (services.formatKeyMap)
172 editor.removeKeyMap(services.formatKeyMap);
173 var editorContext = services.editorContext;
174 var highlightingMarkers = editorContext._highlightingMarkers;
175 if (highlightingMarkers) {
176 for (var i = 0; i < highlightingMarkers.length; i++) {
177 highlightingMarkers[i].clear();
178 }
179 }
180 if (editorContext._validationAnnotations)
181 services.serviceBuilder._clearAnnotations(editorContext._validationAnnotations);
182 var validationMarkers = editorContext._validationMarkers;
183 if (validationMarkers) {
184 for (var i = 0; i < validationMarkers.length; i++) {
185 validationMarkers[i].clear();
186 }
187 }
188 var occurrenceMarkers = editorContext._occurrenceMarkers;
189 if (occurrenceMarkers) {
190 for (var i = 0; i < occurrenceMarkers.length; i++)  {
191 occurrenceMarkers[i].clear();
192 }
193 }
194 delete editor.xtextServices;
195 }
196
197 /**
198 * Syntax highlighting (without semantic highlighting).
199 */
200 CodeMirrorServiceBuilder.prototype.setupSyntaxHighlighting = function() {
201 var options = this.services.options;
202 // If the mode option is set, syntax highlighting has already been configured by CM
203 if (!options.mode && options.syntaxDefinition != 'none' && options.xtextLang) {
204 this.editor.setOption('mode', 'xtext/' + options.xtextLang);
205 }
206 }
207
208 /**
209 * Document update service.
210 */
211 CodeMirrorServiceBuilder.prototype.setupUpdateService = function(refreshDocument) {
212 var services = this.services;
213 var editorContext = services.editorContext;
214 var textUpdateDelay = services.options.textUpdateDelay;
215 if (!textUpdateDelay)
216 textUpdateDelay = 500;
217 services.modelChangeListener = function(event) {
218 if (!event._xtext_init)
219 editorContext.setDirty(true);
220 if (editorContext._modelChangeTimeout)
221 clearTimeout(editorContext._modelChangeTimeout);
222 editorContext._modelChangeTimeout = setTimeout(function() {
223 if (services.options.sendFullText)
224 refreshDocument();
225 else
226 services.update();
227 }, textUpdateDelay);
228 }
229 if (!services.options.resourceId || !services.options.loadFromServer)
230 services.modelChangeListener({_xtext_init: true});
231 this.editor.on('changes', services.modelChangeListener);
232 }
233
234 /**
235 * Persistence services: load, save, and revert.
236 */
237 CodeMirrorServiceBuilder.prototype.setupPersistenceServices = function() {
238 var services = this.services;
239 if (services.options.enableSaveAction) {
240 var userAgent = navigator.userAgent.toLowerCase();
241 var saveFunction = function(editor) {
242 services.saveResource();
243 };
244 services.saveKeyMap = /mac os/.test(userAgent) ? {'Cmd-S': saveFunction}: {'Ctrl-S': saveFunction};
245 this.editor.addKeyMap(services.saveKeyMap);
246 }
247 }
248
249 /**
250 * Content assist service.
251 */
252 CodeMirrorServiceBuilder.prototype.setupContentAssistService = function() {
253 var services = this.services;
254 var editorContext = services.editorContext;
255 services.contentAssistKeyMap = {'Ctrl-Space': function(editor) {
256 var params = ServiceBuilder.copy(services.options);
257 var cursor = editor.getCursor();
258 params.offset = editor.indexFromPos(cursor);
259 services.contentAssistService.invoke(editorContext, params).done(function(entries) {
260 editor.showHint({hint: function(editor, options) {
261 return {
262 list: entries.map(function(entry) {
263 var displayText;
264 if (entry.label)
265 displayText = entry.label;
266 else
267 displayText = entry.proposal;
268 if (entry.description)
269 displayText += ' (' + entry.description + ')';
270 var prefixLength = 0
271 if (entry.prefix)
272 prefixLength = entry.prefix.length
273 return {
274 text: entry.proposal,
275 displayText: displayText,
276 from: {
277 line: cursor.line,
278 ch: cursor.ch - prefixLength
279 }
280 };
281 }),
282 from: cursor,
283 to: cursor
284 };
285 }});
286 });
287 }};
288 this.editor.addKeyMap(services.contentAssistKeyMap);
289 }
290
291 /**
292 * Semantic highlighting service.
293 */
294 CodeMirrorServiceBuilder.prototype.doHighlighting = function() {
295 var services = this.services;
296 var editorContext = services.editorContext;
297 var editor = this.editor;
298 services.computeHighlighting().always(function() {
299 var highlightingMarkers = editorContext._highlightingMarkers;
300 if (highlightingMarkers) {
301 for (var i = 0; i < highlightingMarkers.length; i++) {
302 highlightingMarkers[i].clear();
303 }
304 }
305 editorContext._highlightingMarkers = [];
306 }).done(function(result) {
307 for (var i = 0; i < result.regions.length; ++i) {
308 var region = result.regions[i];
309 var from = editor.posFromIndex(region.offset);
310 var to = editor.posFromIndex(region.offset + region.length);
311 region.styleClasses.forEach(function(styleClass) {
312 var marker = editor.markText(from, to, {className: styleClass});
313 editorContext._highlightingMarkers.push(marker);
314 });
315 }
316 });
317 }
318
319 var annotationWeight = {
320 error: 30,
321 warning: 20,
322 info: 10
323 };
324 CodeMirrorServiceBuilder.prototype._getAnnotationWeight = function(annotation) {
325 if (annotationWeight[annotation] !== undefined)
326 return annotationWeight[annotation];
327 else
328 return 0;
329 }
330
331 CodeMirrorServiceBuilder.prototype._clearAnnotations = function(annotations) {
332 var editor = this.editor;
333 editor.clearGutter('annotations-gutter');
334 for (var i = 0; i < annotations.length; i++) {
335 var annotation = annotations[i];
336 if (annotation) {
337 annotations[i] = undefined;
338 }
339 }
340 }
341
342 CodeMirrorServiceBuilder.prototype._refreshAnnotations = function(annotations) {
343 var editor = this.editor;
344 for (var i = 0; i < annotations.length; i++) {
345 var annotation = annotations[i];
346 if (annotation) {
347 var classProp = ' class="xtext-annotation_' + annotation.type + '"';
348 var titleProp = annotation.description ? ' title="' + annotation.description.replace(/"/g, '&quot;') + '"' : '';
349 var element = jQuery('<div' + classProp + titleProp + '></div>').get(0);
350 editor.setGutterMarker(i, 'annotations-gutter', element);
351 }
352 }
353 }
354
355 /**
356 * Validation service.
357 */
358 CodeMirrorServiceBuilder.prototype.doValidation = function() {
359 var services = this.services;
360 var editorContext = services.editorContext;
361 var editor = this.editor;
362 var self = this;
363 services.validate().always(function() {
364 if (editorContext._validationAnnotations)
365 self._clearAnnotations(editorContext._validationAnnotations);
366 else
367 editorContext._validationAnnotations = [];
368 var validationMarkers = editorContext._validationMarkers;
369 if (validationMarkers) {
370 for (var i = 0; i < validationMarkers.length; i++) {
371 validationMarkers[i].clear();
372 }
373 }
374 editorContext._validationMarkers = [];
375 }).done(function(result) {
376 var validationAnnotations = editorContext._validationAnnotations;
377 for (var i = 0; i < result.issues.length; i++) {
378 var entry = result.issues[i];
379 var annotation = validationAnnotations[entry.line - 1];
380 var weight = self._getAnnotationWeight(entry.severity);
381 if (annotation) {
382 if (annotation.weight < weight) {
383 annotation.type = entry.severity;
384 annotation.weight = weight;
385 }
386 if (annotation.description)
387 annotation.description += '\n' + entry.description;
388 else
389 annotation.description = entry.description;
390 } else {
391 validationAnnotations[entry.line - 1] = {
392 type: entry.severity,
393 weight: weight,
394 description: entry.description
395 };
396 }
397 var from = editor.posFromIndex(entry.offset);
398 var to = editor.posFromIndex(entry.offset + entry.length);
399 var marker = editor.markText(from, to, {
400 className: 'xtext-marker_' + entry.severity,
401 title: entry.description
402 });
403 editorContext._validationMarkers.push(marker);
404 }
405 self._refreshAnnotations(validationAnnotations);
406 });
407 }
408
409 /**
410 * Occurrences service.
411 */
412 CodeMirrorServiceBuilder.prototype.setupOccurrencesService = function() {
413 var services = this.services;
414 var editorContext = services.editorContext;
415 var selectionUpdateDelay = services.options.selectionUpdateDelay;
416 if (!selectionUpdateDelay)
417 selectionUpdateDelay = 550;
418 var editor = this.editor;
419 var self = this;
420 services.cursorActivityListener = function() {
421 if (editorContext._selectionChangeTimeout) {
422 clearTimeout(editorContext._selectionChangeTimeout);
423 }
424 editorContext._selectionChangeTimeout = setTimeout(function() {
425 var params = ServiceBuilder.copy(services.options);
426 var cursor = editor.getCursor();
427 params.offset = editor.indexFromPos(cursor);
428 services.occurrencesService.invoke(editorContext, params).always(function() {
429 var occurrenceMarkers = editorContext._occurrenceMarkers;
430 if (occurrenceMarkers) {
431 for (var i = 0; i < occurrenceMarkers.length; i++)  {
432 occurrenceMarkers[i].clear();
433 }
434 }
435 editorContext._occurrenceMarkers = [];
436 }).done(function(occurrencesResult) {
437 for (var i = 0; i < occurrencesResult.readRegions.length; i++) {
438 var region = occurrencesResult.readRegions[i];
439 var from = editor.posFromIndex(region.offset);
440 var to = editor.posFromIndex(region.offset + region.length);
441 var marker = editor.markText(from, to, {className: 'xtext-marker_read'});
442 editorContext._occurrenceMarkers.push(marker);
443 }
444 for (var i = 0; i < occurrencesResult.writeRegions.length; i++) {
445 var region = occurrencesResult.writeRegions[i];
446 var from = editor.posFromIndex(region.offset);
447 var to = editor.posFromIndex(region.offset + region.length);
448 var marker = editor.markText(from, to, {className: 'xtext-marker_write'});
449 editorContext._occurrenceMarkers.push(marker);
450 }
451 });
452 }, selectionUpdateDelay);
453 }
454 editor.on('cursorActivity', services.cursorActivityListener);
455 }
456
457 /**
458 * Formatting service.
459 */
460 CodeMirrorServiceBuilder.prototype.setupFormattingService = function() {
461 var services = this.services;
462 if (services.options.enableFormattingAction) {
463 var userAgent = navigator.userAgent.toLowerCase();
464 var formatFunction = function(editor) {
465 services.format();
466 };
467 services.formatKeyMap = /mac os/.test(userAgent) ? {'Shift-Cmd-F': formatFunction}: {'Shift-Ctrl-S': formatFunction};
468 this.editor.addKeyMap(services.formatKeyMap);
469 }
470 }
471
472 return exports;
473});
diff --git a/language-web/src/main/js/xtext/xtextMessages.ts b/language-web/src/main/js/xtext/xtextMessages.ts
new file mode 100644
index 00000000..68737958
--- /dev/null
+++ b/language-web/src/main/js/xtext/xtextMessages.ts
@@ -0,0 +1,62 @@
1export interface IXtextWebRequest {
2 id: string;
3
4 request: unknown;
5}
6
7export interface IXtextWebOkResponse {
8 id: string;
9
10 response: unknown;
11}
12
13export function isOkResponse(response: unknown): response is IXtextWebOkResponse {
14 const okResponse = response as IXtextWebOkResponse;
15 return typeof okResponse === 'object'
16 && typeof okResponse.id === 'string'
17 && typeof okResponse.response !== 'undefined';
18}
19
20export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const;
21
22export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number];
23
24export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind {
25 return typeof value === 'string'
26 && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind);
27}
28
29export interface IXtextWebErrorResponse {
30 id: string;
31
32 error: XtextWebErrorKind;
33
34 message: string;
35}
36
37export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse {
38 const errorResponse = response as IXtextWebErrorResponse;
39 return typeof errorResponse === 'object'
40 && typeof errorResponse.id === 'string'
41 && isXtextWebErrorKind(errorResponse.error)
42 && typeof errorResponse.message === 'string';
43}
44
45export interface IXtextWebPushMessage {
46 resource: string;
47
48 stateId: string;
49
50 service: string;
51
52 push: unknown;
53}
54
55export function isPushMessage(response: unknown): response is IXtextWebPushMessage {
56 const pushMessage = response as IXtextWebPushMessage;
57 return typeof pushMessage === 'object'
58 && typeof pushMessage.resource === 'string'
59 && typeof pushMessage.stateId === 'string'
60 && typeof pushMessage.service === 'string'
61 && typeof pushMessage.push !== 'undefined';
62}
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts
new file mode 100644
index 00000000..b2de1e4a
--- /dev/null
+++ b/language-web/src/main/js/xtext/xtextServiceResults.ts
@@ -0,0 +1,239 @@
1export interface IPongResult {
2 pong: string;
3}
4
5export function isPongResult(result: unknown): result is IPongResult {
6 const pongResult = result as IPongResult;
7 return typeof pongResult === 'object'
8 && typeof pongResult.pong === 'string';
9}
10
11export interface IDocumentStateResult {
12 stateId: string;
13}
14
15export function isDocumentStateResult(result: unknown): result is IDocumentStateResult {
16 const documentStateResult = result as IDocumentStateResult;
17 return typeof documentStateResult === 'object'
18 && typeof documentStateResult.stateId === 'string';
19}
20
21export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const;
22
23export type Conflict = typeof VALID_CONFLICTS[number];
24
25export function isConflict(value: unknown): value is Conflict {
26 return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict);
27}
28
29export interface IServiceConflictResult {
30 conflict: Conflict;
31}
32
33export function isServiceConflictResult(result: unknown): result is IServiceConflictResult {
34 const serviceConflictResult = result as IServiceConflictResult;
35 return typeof serviceConflictResult === 'object'
36 && isConflict(serviceConflictResult.conflict);
37}
38
39export function isInvalidStateIdConflictResult(result: unknown): boolean {
40 return isServiceConflictResult(result) && result.conflict === 'invalidStateId';
41}
42
43export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const;
44
45export type Severity = typeof VALID_SEVERITIES[number];
46
47export function isSeverity(value: unknown): value is Severity {
48 return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity);
49}
50
51export interface IIssue {
52 description: string;
53
54 severity: Severity;
55
56 line: number;
57
58 column: number;
59
60 offset: number;
61
62 length: number;
63}
64
65export function isIssue(value: unknown): value is IIssue {
66 const issue = value as IIssue;
67 return typeof issue === 'object'
68 && typeof issue.description === 'string'
69 && isSeverity(issue.severity)
70 && typeof issue.line === 'number'
71 && typeof issue.column === 'number'
72 && typeof issue.offset === 'number'
73 && typeof issue.length === 'number';
74}
75
76export interface IValidationResult {
77 issues: IIssue[];
78}
79
80function isArrayOfType<T>(value: unknown, check: (entry: unknown) => entry is T): value is T[] {
81 return Array.isArray(value) && (value as T[]).every(check);
82}
83
84export function isValidationResult(result: unknown): result is IValidationResult {
85 const validationResult = result as IValidationResult;
86 return typeof validationResult === 'object'
87 && isArrayOfType(validationResult.issues, isIssue);
88}
89
90export interface IReplaceRegion {
91 offset: number;
92
93 length: number;
94
95 text: string;
96}
97
98export function isReplaceRegion(value: unknown): value is IReplaceRegion {
99 const replaceRegion = value as IReplaceRegion;
100 return typeof replaceRegion === 'object'
101 && typeof replaceRegion.offset === 'number'
102 && typeof replaceRegion.length === 'number'
103 && typeof replaceRegion.text === 'string';
104}
105
106export interface ITextRegion {
107 offset: number;
108
109 length: number;
110}
111
112export function isTextRegion(value: unknown): value is ITextRegion {
113 const textRegion = value as ITextRegion;
114 return typeof textRegion === 'object'
115 && typeof textRegion.offset === 'number'
116 && typeof textRegion.length === 'number';
117}
118
119export const VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS = [
120 'TEXT',
121 'METHOD',
122 'FUNCTION',
123 'CONSTRUCTOR',
124 'FIELD',
125 'VARIABLE',
126 'CLASS',
127 'INTERFACE',
128 'MODULE',
129 'PROPERTY',
130 'UNIT',
131 'VALUE',
132 'ENUM',
133 'KEYWORD',
134 'SNIPPET',
135 'COLOR',
136 'FILE',
137 'REFERENCE',
138 'UNKNOWN',
139] as const;
140
141export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number];
142
143export function isXtextContentAssistEntryKind(
144 value: unknown,
145): value is XtextContentAssistEntryKind {
146 return typeof value === 'string'
147 && VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS.includes(value as XtextContentAssistEntryKind);
148}
149
150export interface IContentAssistEntry {
151 prefix: string;
152
153 proposal: string;
154
155 label?: string;
156
157 description?: string;
158
159 documentation?: string;
160
161 escapePosition?: number;
162
163 textReplacements: IReplaceRegion[];
164
165 editPositions: ITextRegion[];
166
167 kind: XtextContentAssistEntryKind | string;
168}
169
170function isStringOrUndefined(value: unknown): value is string | undefined {
171 return typeof value === 'string' || typeof value === 'undefined';
172}
173
174function isNumberOrUndefined(value: unknown): value is number | undefined {
175 return typeof value === 'number' || typeof value === 'undefined';
176}
177
178export function isContentAssistEntry(value: unknown): value is IContentAssistEntry {
179 const entry = value as IContentAssistEntry;
180 return typeof entry === 'object'
181 && typeof entry.prefix === 'string'
182 && typeof entry.proposal === 'string'
183 && isStringOrUndefined(entry.label)
184 && isStringOrUndefined(entry.description)
185 && isStringOrUndefined(entry.documentation)
186 && isNumberOrUndefined(entry.escapePosition)
187 && isArrayOfType(entry.textReplacements, isReplaceRegion)
188 && isArrayOfType(entry.editPositions, isTextRegion)
189 && typeof entry.kind === 'string';
190}
191
192export interface IContentAssistResult extends IDocumentStateResult {
193 entries: IContentAssistEntry[];
194}
195
196export function isContentAssistResult(result: unknown): result is IContentAssistResult {
197 const contentAssistResult = result as IContentAssistResult;
198 return isDocumentStateResult(result)
199 && isArrayOfType(contentAssistResult.entries, isContentAssistEntry);
200}
201
202export interface IHighlightingRegion {
203 offset: number;
204
205 length: number;
206
207 styleClasses: string[];
208}
209
210export function isHighlightingRegion(value: unknown): value is IHighlightingRegion {
211 const region = value as IHighlightingRegion;
212 return typeof region === 'object'
213 && typeof region.offset === 'number'
214 && typeof region.length === 'number'
215 && isArrayOfType(region.styleClasses, (s): s is string => typeof s === 'string');
216}
217
218export interface IHighlightingResult {
219 regions: IHighlightingRegion[];
220}
221
222export function isHighlightingResult(result: unknown): result is IHighlightingResult {
223 const highlightingResult = result as IHighlightingResult;
224 return typeof highlightingResult === 'object'
225 && isArrayOfType(highlightingResult.regions, isHighlightingRegion);
226}
227
228export interface IOccurrencesResult extends IDocumentStateResult {
229 writeRegions: ITextRegion[];
230
231 readRegions: ITextRegion[];
232}
233
234export function isOccurrencesResult(result: unknown): result is IOccurrencesResult {
235 const occurrencesResult = result as IOccurrencesResult;
236 return isDocumentStateResult(occurrencesResult)
237 && isArrayOfType(occurrencesResult.writeRegions, isTextRegion)
238 && isArrayOfType(occurrencesResult.readRegions, isTextRegion);
239}