diff options
author | 2021-11-16 17:09:58 +0100 | |
---|---|---|
committer | 2021-11-16 17:09:58 +0100 | |
commit | b4ba1dc1fec50a8114d49879dcb30891b8a8107e (patch) | |
tree | c40e4af57988becdcf7d363a2fb7107f668b8ba0 | |
parent | chore(lang): fix Sonar issue (diff) | |
parent | feat(web): xtext formatter client (diff) | |
download | refinery-b4ba1dc1fec50a8114d49879dcb30891b8a8107e.tar.gz refinery-b4ba1dc1fec50a8114d49879dcb30891b8a8107e.tar.zst refinery-b4ba1dc1fec50a8114d49879dcb30891b8a8107e.zip |
Merge pull request #13 from kris7t/xtext-formatter
Xtext formatter support
18 files changed, 753 insertions, 372 deletions
diff --git a/language-web/package.json b/language-web/package.json index 3362a47a..54aad155 100644 --- a/language-web/package.json +++ b/language-web/package.json | |||
@@ -95,6 +95,7 @@ | |||
95 | "mobx-react-lite": "^3.2.1", | 95 | "mobx-react-lite": "^3.2.1", |
96 | "nanoid": "^3.1.30", | 96 | "nanoid": "^3.1.30", |
97 | "react": "^17.0.2", | 97 | "react": "^17.0.2", |
98 | "react-dom": "^17.0.2" | 98 | "react-dom": "^17.0.2", |
99 | "zod": "^3.11.6" | ||
99 | } | 100 | } |
100 | } | 101 | } |
diff --git a/language-web/src/main/js/editor/EditorButtons.tsx b/language-web/src/main/js/editor/EditorButtons.tsx index 09ce33dd..150aa00d 100644 --- a/language-web/src/main/js/editor/EditorButtons.tsx +++ b/language-web/src/main/js/editor/EditorButtons.tsx | |||
@@ -7,6 +7,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; | |||
7 | import CheckIcon from '@mui/icons-material/Check'; | 7 | import CheckIcon from '@mui/icons-material/Check'; |
8 | import ErrorIcon from '@mui/icons-material/Error'; | 8 | import ErrorIcon from '@mui/icons-material/Error'; |
9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; | 9 | import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; |
10 | import FormatPaint from '@mui/icons-material/FormatPaint'; | ||
10 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; | 11 | import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |
11 | import RedoIcon from '@mui/icons-material/Redo'; | 12 | import RedoIcon from '@mui/icons-material/Redo'; |
12 | import SearchIcon from '@mui/icons-material/Search'; | 13 | import SearchIcon from '@mui/icons-material/Search'; |
@@ -47,7 +48,6 @@ export const EditorButtons = observer(() => { | |||
47 | disabled={!editorStore.canUndo} | 48 | disabled={!editorStore.canUndo} |
48 | onClick={() => editorStore.undo()} | 49 | onClick={() => editorStore.undo()} |
49 | aria-label="Undo" | 50 | aria-label="Undo" |
50 | value="undo" | ||
51 | > | 51 | > |
52 | <UndoIcon fontSize="small" /> | 52 | <UndoIcon fontSize="small" /> |
53 | </IconButton> | 53 | </IconButton> |
@@ -55,7 +55,6 @@ export const EditorButtons = observer(() => { | |||
55 | disabled={!editorStore.canRedo} | 55 | disabled={!editorStore.canRedo} |
56 | onClick={() => editorStore.redo()} | 56 | onClick={() => editorStore.redo()} |
57 | aria-label="Redo" | 57 | aria-label="Redo" |
58 | value="redo" | ||
59 | > | 58 | > |
60 | <RedoIcon fontSize="small" /> | 59 | <RedoIcon fontSize="small" /> |
61 | </IconButton> | 60 | </IconButton> |
@@ -88,6 +87,12 @@ export const EditorButtons = observer(() => { | |||
88 | {getLintIcon(editorStore.highestDiagnosticLevel)} | 87 | {getLintIcon(editorStore.highestDiagnosticLevel)} |
89 | </ToggleButton> | 88 | </ToggleButton> |
90 | </ToggleButtonGroup> | 89 | </ToggleButtonGroup> |
90 | <IconButton | ||
91 | onClick={() => editorStore.formatText()} | ||
92 | aria-label="Automatic format" | ||
93 | > | ||
94 | <FormatPaint fontSize="small" /> | ||
95 | </IconButton> | ||
91 | </Stack> | 96 | </Stack> |
92 | ); | 97 | ); |
93 | }); | 98 | }); |
diff --git a/language-web/src/main/js/editor/EditorStore.ts b/language-web/src/main/js/editor/EditorStore.ts index 059233f4..5760de28 100644 --- a/language-web/src/main/js/editor/EditorStore.ts +++ b/language-web/src/main/js/editor/EditorStore.ts | |||
@@ -115,6 +115,7 @@ export class EditorStore { | |||
115 | lineNumbers(), | 115 | lineNumbers(), |
116 | foldGutter(), | 116 | foldGutter(), |
117 | keymap.of([ | 117 | keymap.of([ |
118 | { key: 'Mod-Shift-f', run: () => this.formatText() }, | ||
118 | ...closeBracketsKeymap, | 119 | ...closeBracketsKeymap, |
119 | ...commentKeymap, | 120 | ...commentKeymap, |
120 | ...completionKeymap, | 121 | ...completionKeymap, |
@@ -280,4 +281,9 @@ export class EditorStore { | |||
280 | toggleLintPanel(): void { | 281 | toggleLintPanel(): void { |
281 | this.setLintPanelOpen(!this.showLintPanel); | 282 | this.setLintPanelOpen(!this.showLintPanel); |
282 | } | 283 | } |
284 | |||
285 | formatText(): boolean { | ||
286 | this.client.formatText(); | ||
287 | return true; | ||
288 | } | ||
283 | } | 289 | } |
diff --git a/language-web/src/main/js/xtext/ContentAssistService.ts b/language-web/src/main/js/xtext/ContentAssistService.ts index aa9a80b0..8b872e06 100644 --- a/language-web/src/main/js/xtext/ContentAssistService.ts +++ b/language-web/src/main/js/xtext/ContentAssistService.ts | |||
@@ -10,7 +10,7 @@ import escapeStringRegexp from 'escape-string-regexp'; | |||
10 | import { implicitCompletion } from '../language/props'; | 10 | import { implicitCompletion } from '../language/props'; |
11 | import type { UpdateService } from './UpdateService'; | 11 | import type { UpdateService } from './UpdateService'; |
12 | import { getLogger } from '../utils/logger'; | 12 | import { getLogger } from '../utils/logger'; |
13 | import type { IContentAssistEntry } from './xtextServiceResults'; | 13 | import type { ContentAssistEntry } from './xtextServiceResults'; |
14 | 14 | ||
15 | const PROPOSALS_LIMIT = 1000; | 15 | const PROPOSALS_LIMIT = 1000; |
16 | 16 | ||
@@ -67,8 +67,8 @@ function computeSpan(prefix: string, entryCount: number): RegExp { | |||
67 | return new RegExp(`^${escapedPrefix}$`); | 67 | return new RegExp(`^${escapedPrefix}$`); |
68 | } | 68 | } |
69 | 69 | ||
70 | function createCompletion(entry: IContentAssistEntry): Completion { | 70 | function createCompletion(entry: ContentAssistEntry): Completion { |
71 | let boost; | 71 | let boost: number; |
72 | switch (entry.kind) { | 72 | switch (entry.kind) { |
73 | case 'KEYWORD': | 73 | case 'KEYWORD': |
74 | // Some hard-to-type operators should be on top. | 74 | // Some hard-to-type operators should be on top. |
diff --git a/language-web/src/main/js/xtext/HighlightingService.ts b/language-web/src/main/js/xtext/HighlightingService.ts index fc3e9e53..dfbb4a19 100644 --- a/language-web/src/main/js/xtext/HighlightingService.ts +++ b/language-web/src/main/js/xtext/HighlightingService.ts | |||
@@ -1,10 +1,7 @@ | |||
1 | import type { EditorStore } from '../editor/EditorStore'; | 1 | import type { EditorStore } from '../editor/EditorStore'; |
2 | import type { IHighlightRange } from '../editor/semanticHighlighting'; | 2 | import type { IHighlightRange } from '../editor/semanticHighlighting'; |
3 | import type { UpdateService } from './UpdateService'; | 3 | import type { UpdateService } from './UpdateService'; |
4 | import { getLogger } from '../utils/logger'; | 4 | import { highlightingResult } from './xtextServiceResults'; |
5 | import { isHighlightingResult } from './xtextServiceResults'; | ||
6 | |||
7 | const log = getLogger('xtext.ValidationService'); | ||
8 | 5 | ||
9 | export class HighlightingService { | 6 | export class HighlightingService { |
10 | private readonly store: EditorStore; | 7 | private readonly store: EditorStore; |
@@ -17,13 +14,10 @@ export class HighlightingService { | |||
17 | } | 14 | } |
18 | 15 | ||
19 | onPush(push: unknown): void { | 16 | onPush(push: unknown): void { |
20 | if (!isHighlightingResult(push)) { | 17 | const { regions } = highlightingResult.parse(push); |
21 | log.error('Invalid highlighting result', push); | ||
22 | return; | ||
23 | } | ||
24 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | 18 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); |
25 | const ranges: IHighlightRange[] = []; | 19 | const ranges: IHighlightRange[] = []; |
26 | push.regions.forEach(({ offset, length, styleClasses }) => { | 20 | regions.forEach(({ offset, length, styleClasses }) => { |
27 | if (styleClasses.length === 0) { | 21 | if (styleClasses.length === 0) { |
28 | return; | 22 | return; |
29 | } | 23 | } |
diff --git a/language-web/src/main/js/xtext/OccurrencesService.ts b/language-web/src/main/js/xtext/OccurrencesService.ts index d1dec9e9..bc865537 100644 --- a/language-web/src/main/js/xtext/OccurrencesService.ts +++ b/language-web/src/main/js/xtext/OccurrencesService.ts | |||
@@ -7,9 +7,9 @@ import { getLogger } from '../utils/logger'; | |||
7 | import { Timer } from '../utils/Timer'; | 7 | import { Timer } from '../utils/Timer'; |
8 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | 8 | import { XtextWebSocketClient } from './XtextWebSocketClient'; |
9 | import { | 9 | import { |
10 | isOccurrencesResult, | 10 | isConflictResult, |
11 | isServiceConflictResult, | 11 | occurrencesResult, |
12 | ITextRegion, | 12 | TextRegion, |
13 | } from './xtextServiceResults'; | 13 | } from './xtextServiceResults'; |
14 | 14 | ||
15 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; | 15 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; |
@@ -20,7 +20,7 @@ const CLEAR_OCCURRENCES_TIMEOUT_MS = 10; | |||
20 | 20 | ||
21 | const log = getLogger('xtext.OccurrencesService'); | 21 | const log = getLogger('xtext.OccurrencesService'); |
22 | 22 | ||
23 | function transformOccurrences(regions: ITextRegion[]): IOccurrence[] { | 23 | function transformOccurrences(regions: TextRegion[]): IOccurrence[] { |
24 | const occurrences: IOccurrence[] = []; | 24 | const occurrences: IOccurrence[] = []; |
25 | regions.forEach(({ offset, length }) => { | 25 | regions.forEach(({ offset, length }) => { |
26 | if (length > 0) { | 26 | if (length > 0) { |
@@ -87,21 +87,32 @@ export class OccurrencesService { | |||
87 | caretOffset: this.store.state.selection.main.head, | 87 | caretOffset: this.store.state.selection.main.head, |
88 | }); | 88 | }); |
89 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | 89 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); |
90 | if (!allChanges.empty | 90 | if (!allChanges.empty || isConflictResult(result, 'canceled')) { |
91 | || (isServiceConflictResult(result) && result.conflict === 'canceled')) { | ||
92 | // Stale occurrences result, the user already made some changes. | 91 | // Stale occurrences result, the user already made some changes. |
93 | // We can safely ignore the occurrences and schedule a new find occurrences call. | 92 | // We can safely ignore the occurrences and schedule a new find occurrences call. |
94 | this.clearOccurrences(); | 93 | this.clearOccurrences(); |
95 | this.findOccurrencesTimer.schedule(); | 94 | this.findOccurrencesTimer.schedule(); |
96 | return; | 95 | return; |
97 | } | 96 | } |
98 | if (!isOccurrencesResult(result) || result.stateId !== this.updateService.xtextStateId) { | 97 | const parsedOccurrencesResult = occurrencesResult.safeParse(result); |
99 | log.error('Unexpected occurrences result', result); | 98 | if (!parsedOccurrencesResult.success) { |
99 | log.error( | ||
100 | 'Unexpected occurences result', | ||
101 | result, | ||
102 | 'not an OccurrencesResult: ', | ||
103 | parsedOccurrencesResult.error, | ||
104 | ); | ||
100 | this.clearOccurrences(); | 105 | this.clearOccurrences(); |
101 | return; | 106 | return; |
102 | } | 107 | } |
103 | const write = transformOccurrences(result.writeRegions); | 108 | const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; |
104 | const read = transformOccurrences(result.readRegions); | 109 | if (stateId !== this.updateService.xtextStateId) { |
110 | log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); | ||
111 | this.clearOccurrences(); | ||
112 | return; | ||
113 | } | ||
114 | const write = transformOccurrences(writeRegions); | ||
115 | const read = transformOccurrences(readRegions); | ||
105 | this.hasOccurrences = write.length > 0 || read.length > 0; | 116 | this.hasOccurrences = write.length > 0 || read.length > 0; |
106 | log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); | 117 | log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); |
107 | this.store.updateOccurrences(write, read); | 118 | this.store.updateOccurrences(write, read); |
diff --git a/language-web/src/main/js/xtext/UpdateService.ts b/language-web/src/main/js/xtext/UpdateService.ts index 9b672e79..e78944a9 100644 --- a/language-web/src/main/js/xtext/UpdateService.ts +++ b/language-web/src/main/js/xtext/UpdateService.ts | |||
@@ -1,6 +1,8 @@ | |||
1 | import { | 1 | import { |
2 | ChangeDesc, | 2 | ChangeDesc, |
3 | ChangeSet, | 3 | ChangeSet, |
4 | ChangeSpec, | ||
5 | StateEffect, | ||
4 | Transaction, | 6 | Transaction, |
5 | } from '@codemirror/state'; | 7 | } from '@codemirror/state'; |
6 | import { nanoid } from 'nanoid'; | 8 | import { nanoid } from 'nanoid'; |
@@ -11,10 +13,11 @@ import { ConditionVariable } from '../utils/ConditionVariable'; | |||
11 | import { getLogger } from '../utils/logger'; | 13 | import { getLogger } from '../utils/logger'; |
12 | import { Timer } from '../utils/Timer'; | 14 | import { Timer } from '../utils/Timer'; |
13 | import { | 15 | import { |
14 | IContentAssistEntry, | 16 | ContentAssistEntry, |
15 | isContentAssistResult, | 17 | contentAssistResult, |
16 | isDocumentStateResult, | 18 | documentStateResult, |
17 | isInvalidStateIdConflictResult, | 19 | formattingResult, |
20 | isConflictResult, | ||
18 | } from './xtextServiceResults'; | 21 | } from './xtextServiceResults'; |
19 | 22 | ||
20 | const UPDATE_TIMEOUT_MS = 500; | 23 | const UPDATE_TIMEOUT_MS = 500; |
@@ -23,6 +26,8 @@ const WAIT_FOR_UPDATE_TIMEOUT_MS = 1000; | |||
23 | 26 | ||
24 | const log = getLogger('xtext.UpdateService'); | 27 | const log = getLogger('xtext.UpdateService'); |
25 | 28 | ||
29 | const setDirtyChanges = StateEffect.define<ChangeSet>(); | ||
30 | |||
26 | export interface IAbortSignal { | 31 | export interface IAbortSignal { |
27 | aborted: boolean; | 32 | aborted: boolean; |
28 | } | 33 | } |
@@ -38,12 +43,12 @@ export class UpdateService { | |||
38 | * The changes being synchronized to the server if a full or delta text update is running, | 43 | * The changes being synchronized to the server if a full or delta text update is running, |
39 | * `null` otherwise. | 44 | * `null` otherwise. |
40 | */ | 45 | */ |
41 | private pendingUpdate: ChangeDesc | null = null; | 46 | private pendingUpdate: ChangeSet | null = null; |
42 | 47 | ||
43 | /** | 48 | /** |
44 | * Local changes not yet sychronized to the server and not part of the running update, if any. | 49 | * Local changes not yet sychronized to the server and not part of the running update, if any. |
45 | */ | 50 | */ |
46 | private dirtyChanges: ChangeDesc; | 51 | private dirtyChanges: ChangeSet; |
47 | 52 | ||
48 | private readonly webSocketClient: XtextWebSocketClient; | 53 | private readonly webSocketClient: XtextWebSocketClient; |
49 | 54 | ||
@@ -59,7 +64,7 @@ export class UpdateService { | |||
59 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { | 64 | constructor(store: EditorStore, webSocketClient: XtextWebSocketClient) { |
60 | this.resourceName = `${nanoid(7)}.problem`; | 65 | this.resourceName = `${nanoid(7)}.problem`; |
61 | this.store = store; | 66 | this.store = store; |
62 | this.dirtyChanges = this.newEmptyChangeDesc(); | 67 | this.dirtyChanges = this.newEmptyChangeSet(); |
63 | this.webSocketClient = webSocketClient; | 68 | this.webSocketClient = webSocketClient; |
64 | } | 69 | } |
65 | 70 | ||
@@ -71,8 +76,19 @@ export class UpdateService { | |||
71 | } | 76 | } |
72 | 77 | ||
73 | onTransaction(transaction: Transaction): void { | 78 | onTransaction(transaction: Transaction): void { |
79 | const setDirtyChangesEffect = transaction.effects.find( | ||
80 | (effect) => effect.is(setDirtyChanges), | ||
81 | ) as StateEffect<ChangeSet> | undefined; | ||
82 | if (setDirtyChangesEffect) { | ||
83 | const { value } = setDirtyChangesEffect; | ||
84 | if (this.pendingUpdate !== null) { | ||
85 | this.pendingUpdate = ChangeSet.empty(value.length); | ||
86 | } | ||
87 | this.dirtyChanges = value; | ||
88 | return; | ||
89 | } | ||
74 | if (transaction.docChanged) { | 90 | if (transaction.docChanged) { |
75 | this.dirtyChanges = this.dirtyChanges.composeDesc(transaction.changes.desc); | 91 | this.dirtyChanges = this.dirtyChanges.compose(transaction.changes); |
76 | this.idleUpdateTimer.reschedule(); | 92 | this.idleUpdateTimer.reschedule(); |
77 | } | 93 | } |
78 | } | 94 | } |
@@ -86,7 +102,7 @@ export class UpdateService { | |||
86 | * @return the summary of changes since the last update | 102 | * @return the summary of changes since the last update |
87 | */ | 103 | */ |
88 | computeChangesSinceLastUpdate(): ChangeDesc { | 104 | computeChangesSinceLastUpdate(): ChangeDesc { |
89 | return this.pendingUpdate?.composeDesc(this.dirtyChanges) || this.dirtyChanges; | 105 | return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; |
90 | } | 106 | } |
91 | 107 | ||
92 | private handleIdleUpdate() { | 108 | private handleIdleUpdate() { |
@@ -101,9 +117,8 @@ export class UpdateService { | |||
101 | this.idleUpdateTimer.reschedule(); | 117 | this.idleUpdateTimer.reschedule(); |
102 | } | 118 | } |
103 | 119 | ||
104 | private newEmptyChangeDesc() { | 120 | private newEmptyChangeSet() { |
105 | const changeSet = ChangeSet.of([], this.store.state.doc.length); | 121 | return ChangeSet.of([], this.store.state.doc.length); |
106 | return changeSet.desc; | ||
107 | } | 122 | } |
108 | 123 | ||
109 | async updateFullText(): Promise<void> { | 124 | async updateFullText(): Promise<void> { |
@@ -116,11 +131,8 @@ export class UpdateService { | |||
116 | serviceType: 'update', | 131 | serviceType: 'update', |
117 | fullText: this.store.state.doc.sliceString(0), | 132 | fullText: this.store.state.doc.sliceString(0), |
118 | }); | 133 | }); |
119 | if (isDocumentStateResult(result)) { | 134 | const { stateId } = documentStateResult.parse(result); |
120 | return [result.stateId, undefined]; | 135 | return [stateId, undefined]; |
121 | } | ||
122 | log.error('Unexpected full text update result:', result); | ||
123 | throw new Error('Full text update failed'); | ||
124 | } | 136 | } |
125 | 137 | ||
126 | /** | 138 | /** |
@@ -146,14 +158,14 @@ export class UpdateService { | |||
146 | requiredStateId: this.xtextStateId, | 158 | requiredStateId: this.xtextStateId, |
147 | ...delta, | 159 | ...delta, |
148 | }); | 160 | }); |
149 | if (isDocumentStateResult(result)) { | 161 | const parsedDocumentStateResult = documentStateResult.safeParse(result); |
150 | return [result.stateId, undefined]; | 162 | if (parsedDocumentStateResult.success) { |
163 | return [parsedDocumentStateResult.data.stateId, undefined]; | ||
151 | } | 164 | } |
152 | if (isInvalidStateIdConflictResult(result)) { | 165 | if (isConflictResult(result, 'invalidStateId')) { |
153 | return this.doFallbackToUpdateFullText(); | 166 | return this.doFallbackToUpdateFullText(); |
154 | } | 167 | } |
155 | log.error('Unexpected delta text update result:', result); | 168 | throw parsedDocumentStateResult.error; |
156 | throw new Error('Delta text update failed'); | ||
157 | }); | 169 | }); |
158 | } | 170 | } |
159 | 171 | ||
@@ -163,15 +175,15 @@ export class UpdateService { | |||
163 | } | 175 | } |
164 | log.warn('Delta update failed, performing full text update'); | 176 | log.warn('Delta update failed, performing full text update'); |
165 | this.xtextStateId = null; | 177 | this.xtextStateId = null; |
166 | this.pendingUpdate = this.pendingUpdate.composeDesc(this.dirtyChanges); | 178 | this.pendingUpdate = this.pendingUpdate.compose(this.dirtyChanges); |
167 | this.dirtyChanges = this.newEmptyChangeDesc(); | 179 | this.dirtyChanges = this.newEmptyChangeSet(); |
168 | return this.doUpdateFullText(); | 180 | return this.doUpdateFullText(); |
169 | } | 181 | } |
170 | 182 | ||
171 | async fetchContentAssist( | 183 | async fetchContentAssist( |
172 | params: Record<string, unknown>, | 184 | params: Record<string, unknown>, |
173 | signal: IAbortSignal, | 185 | signal: IAbortSignal, |
174 | ): Promise<IContentAssistEntry[]> { | 186 | ): Promise<ContentAssistEntry[]> { |
175 | await this.prepareForDeltaUpdate(); | 187 | await this.prepareForDeltaUpdate(); |
176 | if (signal.aborted) { | 188 | if (signal.aborted) { |
177 | return []; | 189 | return []; |
@@ -185,18 +197,20 @@ export class UpdateService { | |||
185 | requiredStateId: this.xtextStateId, | 197 | requiredStateId: this.xtextStateId, |
186 | ...delta, | 198 | ...delta, |
187 | }); | 199 | }); |
188 | if (isContentAssistResult(result)) { | 200 | const parsedContentAssistResult = contentAssistResult.safeParse(result); |
189 | return [result.stateId, result.entries]; | 201 | if (parsedContentAssistResult.success) { |
202 | const { stateId, entries: resultEntries } = parsedContentAssistResult.data; | ||
203 | return [stateId, resultEntries]; | ||
190 | } | 204 | } |
191 | if (isInvalidStateIdConflictResult(result)) { | 205 | if (isConflictResult(result, 'invalidStateId')) { |
206 | log.warn('Server state invalid during content assist'); | ||
192 | const [newStateId] = await this.doFallbackToUpdateFullText(); | 207 | const [newStateId] = await this.doFallbackToUpdateFullText(); |
193 | // We must finish this state update transaction to prepare for any push events | 208 | // 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 | 209 | // before querying for content assist, so we just return `null` and will query |
195 | // the content assist service later. | 210 | // the content assist service later. |
196 | return [newStateId, null]; | 211 | return [newStateId, null]; |
197 | } | 212 | } |
198 | log.error('Unextpected content assist result with delta update', result); | 213 | throw parsedContentAssistResult.error; |
199 | throw new Error('Unexpexted content assist result with delta update'); | ||
200 | }); | 214 | }); |
201 | if (entries !== null) { | 215 | if (entries !== null) { |
202 | return entries; | 216 | return entries; |
@@ -214,11 +228,36 @@ export class UpdateService { | |||
214 | ...params, | 228 | ...params, |
215 | requiredStateId: expectedStateId, | 229 | requiredStateId: expectedStateId, |
216 | }); | 230 | }); |
217 | if (isContentAssistResult(result) && result.stateId === expectedStateId) { | 231 | const { stateId, entries } = contentAssistResult.parse(result); |
218 | return result.entries; | 232 | if (stateId !== expectedStateId) { |
233 | throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); | ||
219 | } | 234 | } |
220 | log.error('Unexpected content assist result', result); | 235 | return entries; |
221 | throw new Error('Unexpected content assist result'); | 236 | } |
237 | |||
238 | async formatText(): Promise<void> { | ||
239 | await this.update(); | ||
240 | let { from, to } = this.store.state.selection.main; | ||
241 | if (to <= from) { | ||
242 | from = 0; | ||
243 | to = this.store.state.doc.length; | ||
244 | } | ||
245 | log.debug('Formatting from', from, 'to', to); | ||
246 | await this.withUpdate(async () => { | ||
247 | const result = await this.webSocketClient.send({ | ||
248 | resource: this.resourceName, | ||
249 | serviceType: 'format', | ||
250 | selectionStart: from, | ||
251 | selectionEnd: to, | ||
252 | }); | ||
253 | const { stateId, formattedText } = formattingResult.parse(result); | ||
254 | this.applyBeforeDirtyChanges({ | ||
255 | from, | ||
256 | to, | ||
257 | insert: formattedText, | ||
258 | }); | ||
259 | return [stateId, null]; | ||
260 | }); | ||
222 | } | 261 | } |
223 | 262 | ||
224 | private computeDelta() { | 263 | private computeDelta() { |
@@ -242,6 +281,20 @@ export class UpdateService { | |||
242 | }; | 281 | }; |
243 | } | 282 | } |
244 | 283 | ||
284 | private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { | ||
285 | const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; | ||
286 | const revertChanges = pendingChanges.invert(this.store.state.doc); | ||
287 | const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); | ||
288 | const redoChanges = pendingChanges.map(applyBefore.desc); | ||
289 | const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); | ||
290 | this.store.dispatch({ | ||
291 | changes: changeSet, | ||
292 | effects: [ | ||
293 | setDirtyChanges.of(redoChanges), | ||
294 | ], | ||
295 | }); | ||
296 | } | ||
297 | |||
245 | /** | 298 | /** |
246 | * Executes an asynchronous callback that updates the state on the server. | 299 | * Executes an asynchronous callback that updates the state on the server. |
247 | * | 300 | * |
@@ -268,7 +321,7 @@ export class UpdateService { | |||
268 | throw new Error('Another update is pending, will not perform update'); | 321 | throw new Error('Another update is pending, will not perform update'); |
269 | } | 322 | } |
270 | this.pendingUpdate = this.dirtyChanges; | 323 | this.pendingUpdate = this.dirtyChanges; |
271 | this.dirtyChanges = this.newEmptyChangeDesc(); | 324 | this.dirtyChanges = this.newEmptyChangeSet(); |
272 | let newStateId: string | null = null; | 325 | let newStateId: string | null = null; |
273 | try { | 326 | try { |
274 | let result: T; | 327 | let result: T; |
@@ -282,7 +335,7 @@ export class UpdateService { | |||
282 | if (this.pendingUpdate === null) { | 335 | if (this.pendingUpdate === null) { |
283 | log.error('pendingUpdate was cleared during update'); | 336 | log.error('pendingUpdate was cleared during update'); |
284 | } else { | 337 | } else { |
285 | this.dirtyChanges = this.pendingUpdate.composeDesc(this.dirtyChanges); | 338 | this.dirtyChanges = this.pendingUpdate.compose(this.dirtyChanges); |
286 | } | 339 | } |
287 | this.pendingUpdate = null; | 340 | this.pendingUpdate = null; |
288 | this.webSocketClient.forceReconnectOnError(); | 341 | this.webSocketClient.forceReconnectOnError(); |
diff --git a/language-web/src/main/js/xtext/ValidationService.ts b/language-web/src/main/js/xtext/ValidationService.ts index 8e4934ac..c7f6ac7f 100644 --- a/language-web/src/main/js/xtext/ValidationService.ts +++ b/language-web/src/main/js/xtext/ValidationService.ts | |||
@@ -3,9 +3,7 @@ import type { Diagnostic } from '@codemirror/lint'; | |||
3 | import type { EditorStore } from '../editor/EditorStore'; | 3 | import type { EditorStore } from '../editor/EditorStore'; |
4 | import type { UpdateService } from './UpdateService'; | 4 | import type { UpdateService } from './UpdateService'; |
5 | import { getLogger } from '../utils/logger'; | 5 | import { getLogger } from '../utils/logger'; |
6 | import { isValidationResult } from './xtextServiceResults'; | 6 | import { validationResult } from './xtextServiceResults'; |
7 | |||
8 | const log = getLogger('xtext.ValidationService'); | ||
9 | 7 | ||
10 | export class ValidationService { | 8 | export class ValidationService { |
11 | private readonly store: EditorStore; | 9 | private readonly store: EditorStore; |
@@ -18,13 +16,10 @@ export class ValidationService { | |||
18 | } | 16 | } |
19 | 17 | ||
20 | onPush(push: unknown): void { | 18 | onPush(push: unknown): void { |
21 | if (!isValidationResult(push)) { | 19 | const { issues } = validationResult.parse(push); |
22 | log.error('Invalid validation result', push); | ||
23 | return; | ||
24 | } | ||
25 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); | 20 | const allChanges = this.updateService.computeChangesSinceLastUpdate(); |
26 | const diagnostics: Diagnostic[] = []; | 21 | const diagnostics: Diagnostic[] = []; |
27 | push.issues.forEach(({ | 22 | issues.forEach(({ |
28 | offset, | 23 | offset, |
29 | length, | 24 | length, |
30 | severity, | 25 | severity, |
diff --git a/language-web/src/main/js/xtext/XtextClient.ts b/language-web/src/main/js/xtext/XtextClient.ts index 28f3d0cc..0898e725 100644 --- a/language-web/src/main/js/xtext/XtextClient.ts +++ b/language-web/src/main/js/xtext/XtextClient.ts | |||
@@ -12,6 +12,7 @@ import { UpdateService } from './UpdateService'; | |||
12 | import { getLogger } from '../utils/logger'; | 12 | import { getLogger } from '../utils/logger'; |
13 | import { ValidationService } from './ValidationService'; | 13 | import { ValidationService } from './ValidationService'; |
14 | import { XtextWebSocketClient } from './XtextWebSocketClient'; | 14 | import { XtextWebSocketClient } from './XtextWebSocketClient'; |
15 | import { XtextWebPushService } from './xtextMessages'; | ||
15 | 16 | ||
16 | const log = getLogger('xtext.XtextClient'); | 17 | const log = getLogger('xtext.XtextClient'); |
17 | 18 | ||
@@ -52,7 +53,7 @@ export class XtextClient { | |||
52 | this.occurrencesService.onTransaction(transaction); | 53 | this.occurrencesService.onTransaction(transaction); |
53 | } | 54 | } |
54 | 55 | ||
55 | private onPush(resource: string, stateId: string, service: string, push: unknown) { | 56 | private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { |
56 | const { resourceName, xtextStateId } = this.updateService; | 57 | const { resourceName, xtextStateId } = this.updateService; |
57 | if (resource !== resourceName) { | 58 | if (resource !== resourceName) { |
58 | log.error('Unknown resource name: expected:', resourceName, 'got:', resource); | 59 | log.error('Unknown resource name: expected:', resourceName, 'got:', resource); |
@@ -70,14 +71,16 @@ export class XtextClient { | |||
70 | return; | 71 | return; |
71 | case 'validate': | 72 | case 'validate': |
72 | this.validationService.onPush(push); | 73 | this.validationService.onPush(push); |
73 | return; | ||
74 | default: | ||
75 | log.error('Unknown push service:', service); | ||
76 | break; | ||
77 | } | 74 | } |
78 | } | 75 | } |
79 | 76 | ||
80 | contentAssist(context: CompletionContext): Promise<CompletionResult> { | 77 | contentAssist(context: CompletionContext): Promise<CompletionResult> { |
81 | return this.contentAssistService.contentAssist(context); | 78 | return this.contentAssistService.contentAssist(context); |
82 | } | 79 | } |
80 | |||
81 | formatText(): void { | ||
82 | this.updateService.formatText().catch((e) => { | ||
83 | log.error('Error while formatting text', e); | ||
84 | }); | ||
85 | } | ||
83 | } | 86 | } |
diff --git a/language-web/src/main/js/xtext/XtextWebSocketClient.ts b/language-web/src/main/js/xtext/XtextWebSocketClient.ts index 488e4b3b..2ce20a54 100644 --- a/language-web/src/main/js/xtext/XtextWebSocketClient.ts +++ b/language-web/src/main/js/xtext/XtextWebSocketClient.ts | |||
@@ -4,12 +4,13 @@ import { getLogger } from '../utils/logger'; | |||
4 | import { PendingTask } from '../utils/PendingTask'; | 4 | import { PendingTask } from '../utils/PendingTask'; |
5 | import { Timer } from '../utils/Timer'; | 5 | import { Timer } from '../utils/Timer'; |
6 | import { | 6 | import { |
7 | isErrorResponse, | 7 | xtextWebErrorResponse, |
8 | isOkResponse, | 8 | XtextWebRequest, |
9 | isPushMessage, | 9 | xtextWebOkResponse, |
10 | IXtextWebRequest, | 10 | xtextWebPushMessage, |
11 | XtextWebPushService, | ||
11 | } from './xtextMessages'; | 12 | } from './xtextMessages'; |
12 | import { isPongResult } from './xtextServiceResults'; | 13 | import { pongResult } from './xtextServiceResults'; |
13 | 14 | ||
14 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | 15 | const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; |
15 | 16 | ||
@@ -32,7 +33,7 @@ export type ReconnectHandler = () => void; | |||
32 | export type PushHandler = ( | 33 | export type PushHandler = ( |
33 | resourceId: string, | 34 | resourceId: string, |
34 | stateId: string, | 35 | stateId: string, |
35 | service: string, | 36 | service: XtextWebPushService, |
36 | data: unknown, | 37 | data: unknown, |
37 | ) => void; | 38 | ) => void; |
38 | 39 | ||
@@ -192,11 +193,12 @@ export class XtextWebSocketClient { | |||
192 | const ping = nanoid(); | 193 | const ping = nanoid(); |
193 | log.trace('Ping', ping); | 194 | log.trace('Ping', ping); |
194 | this.send({ ping }).then((result) => { | 195 | this.send({ ping }).then((result) => { |
195 | if (isPongResult(result) && result.pong === ping) { | 196 | const parsedPongResult = pongResult.safeParse(result); |
197 | if (parsedPongResult.success && parsedPongResult.data.pong === ping) { | ||
196 | log.trace('Pong', ping); | 198 | log.trace('Pong', ping); |
197 | this.pingTimer.schedule(); | 199 | this.pingTimer.schedule(); |
198 | } else { | 200 | } else { |
199 | log.error('Invalid pong'); | 201 | log.error('Invalid pong:', parsedPongResult, 'expected:', ping); |
200 | this.forceReconnectOnError(); | 202 | this.forceReconnectOnError(); |
201 | } | 203 | } |
202 | }).catch((error) => { | 204 | }).catch((error) => { |
@@ -222,7 +224,7 @@ export class XtextWebSocketClient { | |||
222 | const message = JSON.stringify({ | 224 | const message = JSON.stringify({ |
223 | id: messageId, | 225 | id: messageId, |
224 | request, | 226 | request, |
225 | } as IXtextWebRequest); | 227 | } as XtextWebRequest); |
226 | log.trace('Sending message', message); | 228 | log.trace('Sending message', message); |
227 | return new Promise((resolve, reject) => { | 229 | return new Promise((resolve, reject) => { |
228 | const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { | 230 | const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT_MS, () => { |
@@ -248,23 +250,42 @@ export class XtextWebSocketClient { | |||
248 | this.forceReconnectOnError(); | 250 | this.forceReconnectOnError(); |
249 | return; | 251 | return; |
250 | } | 252 | } |
251 | if (isOkResponse(message)) { | 253 | const okResponse = xtextWebOkResponse.safeParse(message); |
252 | this.resolveRequest(message.id, message.response); | 254 | if (okResponse.success) { |
253 | } else if (isErrorResponse(message)) { | 255 | const { id, response } = okResponse.data; |
254 | this.rejectRequest(message.id, new Error(`${message.error} error: ${message.message}`)); | 256 | this.resolveRequest(id, response); |
255 | if (message.error === 'server') { | 257 | return; |
256 | log.error('Reconnecting due to server error: ', message.message); | 258 | } |
259 | const errorResponse = xtextWebErrorResponse.safeParse(message); | ||
260 | if (errorResponse.success) { | ||
261 | const { id, error, message: errorMessage } = errorResponse.data; | ||
262 | this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); | ||
263 | if (error === 'server') { | ||
264 | log.error('Reconnecting due to server error: ', errorMessage); | ||
257 | this.forceReconnectOnError(); | 265 | this.forceReconnectOnError(); |
258 | } | 266 | } |
259 | } else if (isPushMessage(message)) { | 267 | return; |
260 | this.onPush( | 268 | } |
261 | message.resource, | 269 | const pushMessage = xtextWebPushMessage.safeParse(message); |
262 | message.stateId, | 270 | if (pushMessage.success) { |
263 | message.service, | 271 | const { |
264 | message.push, | 272 | resource, |
265 | ); | 273 | stateId, |
274 | service, | ||
275 | push, | ||
276 | } = pushMessage.data; | ||
277 | this.onPush(resource, stateId, service, push); | ||
266 | } else { | 278 | } else { |
267 | log.error('Unexpected websocket message', message); | 279 | log.error( |
280 | 'Unexpected websocket message:', | ||
281 | message, | ||
282 | 'not ok response because:', | ||
283 | okResponse.error, | ||
284 | 'not error response because:', | ||
285 | errorResponse.error, | ||
286 | 'not push message because:', | ||
287 | pushMessage.error, | ||
288 | ); | ||
268 | this.forceReconnectOnError(); | 289 | this.forceReconnectOnError(); |
269 | } | 290 | } |
270 | } | 291 | } |
diff --git a/language-web/src/main/js/xtext/xtextMessages.ts b/language-web/src/main/js/xtext/xtextMessages.ts index 68737958..c4305fcf 100644 --- a/language-web/src/main/js/xtext/xtextMessages.ts +++ b/language-web/src/main/js/xtext/xtextMessages.ts | |||
@@ -1,62 +1,40 @@ | |||
1 | export interface IXtextWebRequest { | 1 | import { z } from 'zod'; |
2 | id: string; | ||
3 | 2 | ||
4 | request: unknown; | 3 | export const xtextWebRequest = z.object({ |
5 | } | 4 | id: z.string().nonempty(), |
5 | request: z.unknown(), | ||
6 | }); | ||
6 | 7 | ||
7 | export interface IXtextWebOkResponse { | 8 | export type XtextWebRequest = z.infer<typeof xtextWebRequest>; |
8 | id: string; | ||
9 | 9 | ||
10 | response: unknown; | 10 | export const xtextWebOkResponse = z.object({ |
11 | } | 11 | id: z.string().nonempty(), |
12 | response: z.unknown(), | ||
13 | }); | ||
12 | 14 | ||
13 | export function isOkResponse(response: unknown): response is IXtextWebOkResponse { | 15 | export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>; |
14 | const okResponse = response as IXtextWebOkResponse; | ||
15 | return typeof okResponse === 'object' | ||
16 | && typeof okResponse.id === 'string' | ||
17 | && typeof okResponse.response !== 'undefined'; | ||
18 | } | ||
19 | 16 | ||
20 | export const VALID_XTEXT_WEB_ERROR_KINDS = ['request', 'server'] as const; | 17 | export const xtextWebErrorKind = z.enum(['request', 'server']); |
21 | 18 | ||
22 | export type XtextWebErrorKind = typeof VALID_XTEXT_WEB_ERROR_KINDS[number]; | 19 | export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>; |
23 | 20 | ||
24 | export function isXtextWebErrorKind(value: unknown): value is XtextWebErrorKind { | 21 | export const xtextWebErrorResponse = z.object({ |
25 | return typeof value === 'string' | 22 | id: z.string().nonempty(), |
26 | && VALID_XTEXT_WEB_ERROR_KINDS.includes(value as XtextWebErrorKind); | 23 | error: xtextWebErrorKind, |
27 | } | 24 | message: z.string(), |
25 | }); | ||
28 | 26 | ||
29 | export interface IXtextWebErrorResponse { | 27 | export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>; |
30 | id: string; | ||
31 | 28 | ||
32 | error: XtextWebErrorKind; | 29 | export const xtextWebPushService = z.enum(['highlight', 'validate']); |
33 | 30 | ||
34 | message: string; | 31 | export type XtextWebPushService = z.infer<typeof xtextWebPushService>; |
35 | } | ||
36 | 32 | ||
37 | export function isErrorResponse(response: unknown): response is IXtextWebErrorResponse { | 33 | export const xtextWebPushMessage = z.object({ |
38 | const errorResponse = response as IXtextWebErrorResponse; | 34 | resource: z.string().nonempty(), |
39 | return typeof errorResponse === 'object' | 35 | stateId: z.string().nonempty(), |
40 | && typeof errorResponse.id === 'string' | 36 | service: xtextWebPushService, |
41 | && isXtextWebErrorKind(errorResponse.error) | 37 | push: z.unknown(), |
42 | && typeof errorResponse.message === 'string'; | 38 | }); |
43 | } | ||
44 | 39 | ||
45 | export interface IXtextWebPushMessage { | 40 | export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>; |
46 | resource: string; | ||
47 | |||
48 | stateId: string; | ||
49 | |||
50 | service: string; | ||
51 | |||
52 | push: unknown; | ||
53 | } | ||
54 | |||
55 | export function isPushMessage(response: unknown): response is IXtextWebPushMessage { | ||
56 | const pushMessage = response as IXtextWebPushMessage; | ||
57 | return typeof pushMessage === 'object' | ||
58 | && typeof pushMessage.resource === 'string' | ||
59 | && typeof pushMessage.stateId === 'string' | ||
60 | && typeof pushMessage.service === 'string' | ||
61 | && typeof pushMessage.push !== 'undefined'; | ||
62 | } | ||
diff --git a/language-web/src/main/js/xtext/xtextServiceResults.ts b/language-web/src/main/js/xtext/xtextServiceResults.ts index b2de1e4a..f79b059c 100644 --- a/language-web/src/main/js/xtext/xtextServiceResults.ts +++ b/language-web/src/main/js/xtext/xtextServiceResults.ts | |||
@@ -1,239 +1,112 @@ | |||
1 | export interface IPongResult { | 1 | import { z } from 'zod'; |
2 | pong: string; | ||
3 | } | ||
4 | |||
5 | export function isPongResult(result: unknown): result is IPongResult { | ||
6 | const pongResult = result as IPongResult; | ||
7 | return typeof pongResult === 'object' | ||
8 | && typeof pongResult.pong === 'string'; | ||
9 | } | ||
10 | |||
11 | export interface IDocumentStateResult { | ||
12 | stateId: string; | ||
13 | } | ||
14 | |||
15 | export function isDocumentStateResult(result: unknown): result is IDocumentStateResult { | ||
16 | const documentStateResult = result as IDocumentStateResult; | ||
17 | return typeof documentStateResult === 'object' | ||
18 | && typeof documentStateResult.stateId === 'string'; | ||
19 | } | ||
20 | |||
21 | export const VALID_CONFLICTS = ['invalidStateId', 'canceled'] as const; | ||
22 | |||
23 | export type Conflict = typeof VALID_CONFLICTS[number]; | ||
24 | |||
25 | export function isConflict(value: unknown): value is Conflict { | ||
26 | return typeof value === 'string' && VALID_CONFLICTS.includes(value as Conflict); | ||
27 | } | ||
28 | |||
29 | export interface IServiceConflictResult { | ||
30 | conflict: Conflict; | ||
31 | } | ||
32 | |||
33 | export function isServiceConflictResult(result: unknown): result is IServiceConflictResult { | ||
34 | const serviceConflictResult = result as IServiceConflictResult; | ||
35 | return typeof serviceConflictResult === 'object' | ||
36 | && isConflict(serviceConflictResult.conflict); | ||
37 | } | ||
38 | |||
39 | export function isInvalidStateIdConflictResult(result: unknown): boolean { | ||
40 | return isServiceConflictResult(result) && result.conflict === 'invalidStateId'; | ||
41 | } | ||
42 | |||
43 | export const VALID_SEVERITIES = ['error', 'warning', 'info', 'ignore'] as const; | ||
44 | |||
45 | export type Severity = typeof VALID_SEVERITIES[number]; | ||
46 | |||
47 | export function isSeverity(value: unknown): value is Severity { | ||
48 | return typeof value === 'string' && VALID_SEVERITIES.includes(value as Severity); | ||
49 | } | ||
50 | |||
51 | export interface IIssue { | ||
52 | description: string; | ||
53 | |||
54 | severity: Severity; | ||
55 | |||
56 | line: number; | ||
57 | |||
58 | column: number; | ||
59 | |||
60 | offset: number; | ||
61 | 2 | ||
62 | length: number; | 3 | export const pongResult = z.object({ |
63 | } | 4 | pong: z.string().nonempty(), |
5 | }); | ||
64 | 6 | ||
65 | export function isIssue(value: unknown): value is IIssue { | 7 | export type PongResult = z.infer<typeof pongResult>; |
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 | 8 | ||
76 | export interface IValidationResult { | 9 | export const documentStateResult = z.object({ |
77 | issues: IIssue[]; | 10 | stateId: z.string().nonempty(), |
78 | } | 11 | }); |
79 | 12 | ||
80 | function isArrayOfType<T>(value: unknown, check: (entry: unknown) => entry is T): value is T[] { | 13 | export type DocumentStateResult = z.infer<typeof documentStateResult>; |
81 | return Array.isArray(value) && (value as T[]).every(check); | ||
82 | } | ||
83 | 14 | ||
84 | export function isValidationResult(result: unknown): result is IValidationResult { | 15 | export const conflict = z.enum(['invalidStateId', 'canceled']); |
85 | const validationResult = result as IValidationResult; | ||
86 | return typeof validationResult === 'object' | ||
87 | && isArrayOfType(validationResult.issues, isIssue); | ||
88 | } | ||
89 | |||
90 | export interface IReplaceRegion { | ||
91 | offset: number; | ||
92 | 16 | ||
93 | length: number; | 17 | export type Conflict = z.infer<typeof conflict>; |
94 | 18 | ||
95 | text: string; | 19 | export const serviceConflictResult = z.object({ |
96 | } | 20 | conflict, |
21 | }); | ||
97 | 22 | ||
98 | export function isReplaceRegion(value: unknown): value is IReplaceRegion { | 23 | export type ServiceConflictResult = z.infer<typeof serviceConflictResult>; |
99 | const replaceRegion = value as IReplaceRegion; | ||
100 | return typeof replaceRegion === 'object' | ||
101 | && typeof replaceRegion.offset === 'number' | ||
102 | && typeof replaceRegion.length === 'number' | ||
103 | && typeof replaceRegion.text === 'string'; | ||
104 | } | ||
105 | |||
106 | export interface ITextRegion { | ||
107 | offset: number; | ||
108 | |||
109 | length: number; | ||
110 | } | ||
111 | |||
112 | export function isTextRegion(value: unknown): value is ITextRegion { | ||
113 | const textRegion = value as ITextRegion; | ||
114 | return typeof textRegion === 'object' | ||
115 | && typeof textRegion.offset === 'number' | ||
116 | && typeof textRegion.length === 'number'; | ||
117 | } | ||
118 | 24 | ||
119 | export const VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS = [ | 25 | export function isConflictResult(result: unknown, conflictType: Conflict): boolean { |
120 | 'TEXT', | 26 | const parsedConflictResult = serviceConflictResult.safeParse(result); |
121 | 'METHOD', | 27 | return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; |
122 | 'FUNCTION', | ||
123 | 'CONSTRUCTOR', | ||
124 | 'FIELD', | ||
125 | 'VARIABLE', | ||
126 | 'CLASS', | ||
127 | 'INTERFACE', | ||
128 | 'MODULE', | ||
129 | 'PROPERTY', | ||
130 | 'UNIT', | ||
131 | 'VALUE', | ||
132 | 'ENUM', | ||
133 | 'KEYWORD', | ||
134 | 'SNIPPET', | ||
135 | 'COLOR', | ||
136 | 'FILE', | ||
137 | 'REFERENCE', | ||
138 | 'UNKNOWN', | ||
139 | ] as const; | ||
140 | |||
141 | export type XtextContentAssistEntryKind = typeof VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS[number]; | ||
142 | |||
143 | export function isXtextContentAssistEntryKind( | ||
144 | value: unknown, | ||
145 | ): value is XtextContentAssistEntryKind { | ||
146 | return typeof value === 'string' | ||
147 | && VALID_XTEXT_CONTENT_ASSIST_ENTRY_KINDS.includes(value as XtextContentAssistEntryKind); | ||
148 | } | 28 | } |
149 | 29 | ||
150 | export interface IContentAssistEntry { | 30 | export const severity = z.enum(['error', 'warning', 'info', 'ignore']); |
151 | prefix: string; | ||
152 | 31 | ||
153 | proposal: string; | 32 | export type Severity = z.infer<typeof severity>; |
154 | 33 | ||
155 | label?: string; | 34 | export const issue = z.object({ |
35 | description: z.string().nonempty(), | ||
36 | severity, | ||
37 | line: z.number().int(), | ||
38 | column: z.number().int().nonnegative(), | ||
39 | offset: z.number().int().nonnegative(), | ||
40 | length: z.number().int().nonnegative(), | ||
41 | }); | ||
156 | 42 | ||
157 | description?: string; | 43 | export type Issue = z.infer<typeof issue>; |
158 | 44 | ||
159 | documentation?: string; | 45 | export const validationResult = z.object({ |
46 | issues: issue.array(), | ||
47 | }); | ||
160 | 48 | ||
161 | escapePosition?: number; | 49 | export type ValidationResult = z.infer<typeof validationResult>; |
162 | 50 | ||
163 | textReplacements: IReplaceRegion[]; | 51 | export const replaceRegion = z.object({ |
52 | offset: z.number().int().nonnegative(), | ||
53 | length: z.number().int().nonnegative(), | ||
54 | text: z.string(), | ||
55 | }); | ||
164 | 56 | ||
165 | editPositions: ITextRegion[]; | 57 | export type ReplaceRegion = z.infer<typeof replaceRegion>; |
166 | 58 | ||
167 | kind: XtextContentAssistEntryKind | string; | 59 | export const textRegion = z.object({ |
168 | } | 60 | offset: z.number().int().nonnegative(), |
61 | length: z.number().int().nonnegative(), | ||
62 | }); | ||
169 | 63 | ||
170 | function isStringOrUndefined(value: unknown): value is string | undefined { | 64 | export type TextRegion = z.infer<typeof textRegion>; |
171 | return typeof value === 'string' || typeof value === 'undefined'; | ||
172 | } | ||
173 | 65 | ||
174 | function isNumberOrUndefined(value: unknown): value is number | undefined { | 66 | export const contentAssistEntry = z.object({ |
175 | return typeof value === 'number' || typeof value === 'undefined'; | 67 | prefix: z.string(), |
176 | } | 68 | proposal: z.string().nonempty(), |
69 | label: z.string().optional(), | ||
70 | description: z.string().nonempty().optional(), | ||
71 | documentation: z.string().nonempty().optional(), | ||
72 | escapePosition: z.number().int().nonnegative().optional(), | ||
73 | textReplacements: replaceRegion.array(), | ||
74 | editPositions: textRegion.array(), | ||
75 | kind: z.string().nonempty(), | ||
76 | }); | ||
177 | 77 | ||
178 | export function isContentAssistEntry(value: unknown): value is IContentAssistEntry { | 78 | export type ContentAssistEntry = z.infer<typeof contentAssistEntry>; |
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 | 79 | ||
192 | export interface IContentAssistResult extends IDocumentStateResult { | 80 | export const contentAssistResult = documentStateResult.extend({ |
193 | entries: IContentAssistEntry[]; | 81 | entries: contentAssistEntry.array(), |
194 | } | 82 | }); |
195 | 83 | ||
196 | export function isContentAssistResult(result: unknown): result is IContentAssistResult { | 84 | export type ContentAssistResult = z.infer<typeof contentAssistResult>; |
197 | const contentAssistResult = result as IContentAssistResult; | ||
198 | return isDocumentStateResult(result) | ||
199 | && isArrayOfType(contentAssistResult.entries, isContentAssistEntry); | ||
200 | } | ||
201 | 85 | ||
202 | export interface IHighlightingRegion { | 86 | export const highlightingRegion = z.object({ |
203 | offset: number; | 87 | offset: z.number().int().nonnegative(), |
88 | length: z.number().int().nonnegative(), | ||
89 | styleClasses: z.string().nonempty().array(), | ||
90 | }); | ||
204 | 91 | ||
205 | length: number; | 92 | export type HighlightingRegion = z.infer<typeof highlightingRegion>; |
206 | 93 | ||
207 | styleClasses: string[]; | 94 | export const highlightingResult = z.object({ |
208 | } | 95 | regions: highlightingRegion.array(), |
96 | }); | ||
209 | 97 | ||
210 | export function isHighlightingRegion(value: unknown): value is IHighlightingRegion { | 98 | export type HighlightingResult = z.infer<typeof highlightingResult>; |
211 | const region = value as IHighlightingRegion; | ||
212 | return typeof region === 'object' | ||
213 | && typeof region.offset === 'number' | ||
214 | && typeof region.length === 'number' | ||
215 | && isArrayOfType(region.styleClasses, (s): s is string => typeof s === 'string'); | ||
216 | } | ||
217 | |||
218 | export interface IHighlightingResult { | ||
219 | regions: IHighlightingRegion[]; | ||
220 | } | ||
221 | 99 | ||
222 | export function isHighlightingResult(result: unknown): result is IHighlightingResult { | 100 | export const occurrencesResult = documentStateResult.extend({ |
223 | const highlightingResult = result as IHighlightingResult; | 101 | writeRegions: textRegion.array(), |
224 | return typeof highlightingResult === 'object' | 102 | readRegions: textRegion.array(), |
225 | && isArrayOfType(highlightingResult.regions, isHighlightingRegion); | 103 | }); |
226 | } | ||
227 | 104 | ||
228 | export interface IOccurrencesResult extends IDocumentStateResult { | 105 | export type OccurrencesResult = z.infer<typeof occurrencesResult>; |
229 | writeRegions: ITextRegion[]; | ||
230 | 106 | ||
231 | readRegions: ITextRegion[]; | 107 | export const formattingResult = documentStateResult.extend({ |
232 | } | 108 | formattedText: z.string(), |
109 | replaceRegion: textRegion, | ||
110 | }); | ||
233 | 111 | ||
234 | export function isOccurrencesResult(result: unknown): result is IOccurrencesResult { | 112 | export type FormattingResult = z.infer<typeof formattingResult>; |
235 | const occurrencesResult = result as IOccurrencesResult; | ||
236 | return isDocumentStateResult(occurrencesResult) | ||
237 | && isArrayOfType(occurrencesResult.writeRegions, isTextRegion) | ||
238 | && isArrayOfType(occurrencesResult.readRegions, isTextRegion); | ||
239 | } | ||
diff --git a/language-web/yarn.lock b/language-web/yarn.lock index 06b30508..ebebe1ff 100644 --- a/language-web/yarn.lock +++ b/language-web/yarn.lock | |||
@@ -7784,3 +7784,8 @@ yocto-queue@^0.1.0: | |||
7784 | version "0.1.0" | 7784 | version "0.1.0" |
7785 | resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" | 7785 | resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" |
7786 | integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== | 7786 | integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== |
7787 | |||
7788 | zod@^3.11.6: | ||
7789 | version "3.11.6" | ||
7790 | resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483" | ||
7791 | integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== | ||
diff --git a/language/src/main/java/tools/refinery/language/GenerateProblem.mwe2 b/language/src/main/java/tools/refinery/language/GenerateProblem.mwe2 index 58620d6a..0d934b68 100644 --- a/language/src/main/java/tools/refinery/language/GenerateProblem.mwe2 +++ b/language/src/main/java/tools/refinery/language/GenerateProblem.mwe2 | |||
@@ -39,6 +39,9 @@ Workflow { | |||
39 | serializer = { | 39 | serializer = { |
40 | generateStub = false | 40 | generateStub = false |
41 | } | 41 | } |
42 | formatter = { | ||
43 | generateStub = true | ||
44 | } | ||
42 | validator = { | 45 | validator = { |
43 | generateDeprecationValidation = true | 46 | generateDeprecationValidation = true |
44 | } | 47 | } |
diff --git a/language/src/main/java/tools/refinery/language/Problem.xtext b/language/src/main/java/tools/refinery/language/Problem.xtext index 26773047..c94d40ab 100644 --- a/language/src/main/java/tools/refinery/language/Problem.xtext +++ b/language/src/main/java/tools/refinery/language/Problem.xtext | |||
@@ -8,7 +8,8 @@ Problem: | |||
8 | statements+=Statement*; | 8 | statements+=Statement*; |
9 | 9 | ||
10 | Statement: | 10 | Statement: |
11 | ClassDeclaration | EnumDeclaration | PredicateDefinition | RuleDefinition | Assertion | NodeValueAssertion | ScopeDeclaration | | 11 | ClassDeclaration | EnumDeclaration | PredicateDefinition | RuleDefinition | Assertion | NodeValueAssertion | |
12 | ScopeDeclaration | | ||
12 | IndividualDeclaration; | 13 | IndividualDeclaration; |
13 | 14 | ||
14 | ClassDeclaration: | 15 | ClassDeclaration: |
@@ -67,7 +68,7 @@ Literal: | |||
67 | 68 | ||
68 | ValueLiteral: | 69 | ValueLiteral: |
69 | atom=Atom | 70 | atom=Atom |
70 | (refinement?=":"|"=") | 71 | (refinement?=":" | "=") |
71 | values+=LogicConstant ("|" values+=LogicConstant)*; | 72 | values+=LogicConstant ("|" values+=LogicConstant)*; |
72 | 73 | ||
73 | NegativeLiteral: | 74 | NegativeLiteral: |
@@ -78,7 +79,7 @@ ActionLiteral: | |||
78 | 79 | ||
79 | ValueActionLiteral: | 80 | ValueActionLiteral: |
80 | atom=Atom | 81 | atom=Atom |
81 | (refinement?=":"|"=") | 82 | (refinement?=":" | "=") |
82 | value=LogicValue; | 83 | value=LogicValue; |
83 | 84 | ||
84 | DeleteActionLiteral: | 85 | DeleteActionLiteral: |
@@ -86,7 +87,7 @@ DeleteActionLiteral: | |||
86 | 87 | ||
87 | NewActionLiteral: | 88 | NewActionLiteral: |
88 | "new" variable=NewVariable; | 89 | "new" variable=NewVariable; |
89 | 90 | ||
90 | NewVariable: | 91 | NewVariable: |
91 | name=Identifier; | 92 | name=Identifier; |
92 | 93 | ||
diff --git a/language/src/main/java/tools/refinery/language/formatting2/ProblemFormatter.java b/language/src/main/java/tools/refinery/language/formatting2/ProblemFormatter.java new file mode 100644 index 00000000..903347f7 --- /dev/null +++ b/language/src/main/java/tools/refinery/language/formatting2/ProblemFormatter.java | |||
@@ -0,0 +1,183 @@ | |||
1 | /* | ||
2 | * generated by Xtext 2.26.0.M2 | ||
3 | */ | ||
4 | package tools.refinery.language.formatting2; | ||
5 | |||
6 | import org.eclipse.emf.ecore.EObject; | ||
7 | import org.eclipse.xtext.formatting2.AbstractJavaFormatter; | ||
8 | import org.eclipse.xtext.formatting2.IFormattableDocument; | ||
9 | import org.eclipse.xtext.formatting2.IHiddenRegionFormatter; | ||
10 | import org.eclipse.xtext.formatting2.regionaccess.ISemanticRegionsFinder; | ||
11 | import org.eclipse.xtext.formatting2.regionaccess.ISequentialRegion; | ||
12 | import org.eclipse.xtext.xbase.lib.Procedures.Procedure1; | ||
13 | |||
14 | import tools.refinery.language.model.problem.Assertion; | ||
15 | import tools.refinery.language.model.problem.Atom; | ||
16 | import tools.refinery.language.model.problem.ClassDeclaration; | ||
17 | import tools.refinery.language.model.problem.Conjunction; | ||
18 | import tools.refinery.language.model.problem.IndividualDeclaration; | ||
19 | import tools.refinery.language.model.problem.NegativeLiteral; | ||
20 | import tools.refinery.language.model.problem.Parameter; | ||
21 | import tools.refinery.language.model.problem.PredicateDefinition; | ||
22 | import tools.refinery.language.model.problem.Problem; | ||
23 | import tools.refinery.language.model.problem.ProblemPackage; | ||
24 | |||
25 | public class ProblemFormatter extends AbstractJavaFormatter { | ||
26 | |||
27 | protected void format(Problem problem, IFormattableDocument doc) { | ||
28 | doc.prepend(problem, this::noSpace); | ||
29 | var region = regionFor(problem); | ||
30 | doc.append(region.keyword("problem"), this::oneSpace); | ||
31 | doc.prepend(region.keyword("."), this::noSpace); | ||
32 | appendNewLines(doc, region.keyword("."), this::twoNewLines); | ||
33 | for (var statement : problem.getStatements()) { | ||
34 | doc.format(statement); | ||
35 | } | ||
36 | } | ||
37 | |||
38 | protected void format(Assertion assertion, IFormattableDocument doc) { | ||
39 | surroundNewLines(doc, assertion, this::singleNewLine); | ||
40 | var region = regionFor(assertion); | ||
41 | doc.append(region.feature(ProblemPackage.Literals.ASSERTION__DEFAULT), this::oneSpace); | ||
42 | doc.append(region.feature(ProblemPackage.Literals.ASSERTION__VALUE), this::noSpace); | ||
43 | doc.append(region.feature(ProblemPackage.Literals.ASSERTION__RELATION), this::noSpace); | ||
44 | formatParenthesizedList(region, doc); | ||
45 | doc.prepend(region.keyword(":"), this::noSpace); | ||
46 | doc.append(region.keyword(":"), this::oneSpace); | ||
47 | doc.prepend(region.keyword("."), this::noSpace); | ||
48 | for (var argument : assertion.getArguments()) { | ||
49 | doc.format(argument); | ||
50 | } | ||
51 | } | ||
52 | |||
53 | protected void format(ClassDeclaration classDeclaration, IFormattableDocument doc) { | ||
54 | surroundNewLines(doc, classDeclaration, this::twoNewLines); | ||
55 | var region = regionFor(classDeclaration); | ||
56 | doc.append(region.feature(ProblemPackage.Literals.CLASS_DECLARATION__ABSTRACT), this::oneSpace); | ||
57 | doc.append(region.keyword("class"), this::oneSpace); | ||
58 | doc.surround(region.keyword("extends"), this::oneSpace); | ||
59 | formatList(region, ",", doc); | ||
60 | doc.prepend(region.keyword("{"), this::oneSpace); | ||
61 | doc.append(region.keyword("{"), it -> it.setNewLines(1, 1, 2)); | ||
62 | doc.prepend(region.keyword("}"), it -> it.setNewLines(1, 1, 2)); | ||
63 | doc.prepend(region.keyword("."), this::noSpace); | ||
64 | for (var referenceDeclaration : classDeclaration.getReferenceDeclarations()) { | ||
65 | doc.format(referenceDeclaration); | ||
66 | } | ||
67 | } | ||
68 | |||
69 | protected void format(PredicateDefinition predicateDefinition, IFormattableDocument doc) { | ||
70 | surroundNewLines(doc, predicateDefinition, this::twoNewLines); | ||
71 | var region = regionFor(predicateDefinition); | ||
72 | doc.append(region.feature(ProblemPackage.Literals.PREDICATE_DEFINITION__KIND), this::oneSpace); | ||
73 | doc.append(region.keyword("pred"), this::oneSpace); | ||
74 | doc.append(region.feature(ProblemPackage.Literals.NAMED_ELEMENT__NAME), this::noSpace); | ||
75 | formatParenthesizedList(region, doc); | ||
76 | doc.surround(region.keyword("<->"), this::oneSpace); | ||
77 | formatList(region, ";", doc); | ||
78 | doc.prepend(region.keyword("."), this::noSpace); | ||
79 | for (var parameter : predicateDefinition.getParameters()) { | ||
80 | doc.format(parameter); | ||
81 | } | ||
82 | for (var body : predicateDefinition.getBodies()) { | ||
83 | doc.format(body); | ||
84 | } | ||
85 | } | ||
86 | |||
87 | protected void format(Parameter parameter, IFormattableDocument doc) { | ||
88 | doc.append(regionFor(parameter).feature(ProblemPackage.Literals.PARAMETER__PARAMETER_TYPE), this::oneSpace); | ||
89 | } | ||
90 | |||
91 | protected void format(Conjunction conjunction, IFormattableDocument doc) { | ||
92 | var region = regionFor(conjunction); | ||
93 | formatList(region, ",", doc); | ||
94 | for (var literal : conjunction.getLiterals()) { | ||
95 | doc.format(literal); | ||
96 | } | ||
97 | } | ||
98 | |||
99 | protected void format(NegativeLiteral literal, IFormattableDocument doc) { | ||
100 | var region = regionFor(literal); | ||
101 | doc.append(region.keyword("!"), this::noSpace); | ||
102 | doc.format(literal.getAtom()); | ||
103 | } | ||
104 | |||
105 | protected void format(Atom atom, IFormattableDocument doc) { | ||
106 | var region = regionFor(atom); | ||
107 | doc.append(region.feature(ProblemPackage.Literals.ATOM__RELATION), this::noSpace); | ||
108 | doc.append(region.feature(ProblemPackage.Literals.ATOM__TRANSITIVE_CLOSURE), this::noSpace); | ||
109 | formatParenthesizedList(region, doc); | ||
110 | for (var argument : atom.getArguments()) { | ||
111 | doc.format(argument); | ||
112 | } | ||
113 | } | ||
114 | |||
115 | protected void format(IndividualDeclaration individualDeclaration, IFormattableDocument doc) { | ||
116 | surroundNewLines(doc, individualDeclaration, this::singleNewLine); | ||
117 | var region = regionFor(individualDeclaration); | ||
118 | doc.append(region.keyword("indiv"), this::oneSpace); | ||
119 | formatList(region, ",", doc); | ||
120 | doc.prepend(region.keyword("."), this::noSpace); | ||
121 | } | ||
122 | |||
123 | protected void formatParenthesizedList(ISemanticRegionsFinder region, IFormattableDocument doc) { | ||
124 | doc.append(region.keyword("("), this::noSpace); | ||
125 | doc.prepend(region.keyword(")"), this::noSpace); | ||
126 | formatList(region, ",", doc); | ||
127 | } | ||
128 | |||
129 | protected void formatList(ISemanticRegionsFinder region, String separator, IFormattableDocument doc) { | ||
130 | for (var comma : region.keywords(separator)) { | ||
131 | doc.prepend(comma, this::noSpace); | ||
132 | doc.append(comma, this::oneSpace); | ||
133 | } | ||
134 | } | ||
135 | |||
136 | protected void singleNewLine(IHiddenRegionFormatter it) { | ||
137 | it.setNewLines(1, 1, 2); | ||
138 | } | ||
139 | |||
140 | protected void twoNewLines(IHiddenRegionFormatter it) { | ||
141 | it.highPriority(); | ||
142 | it.setNewLines(2); | ||
143 | } | ||
144 | |||
145 | protected void surroundNewLines(IFormattableDocument doc, EObject eObject, | ||
146 | Procedure1<? super IHiddenRegionFormatter> init) { | ||
147 | var region = doc.getRequest().getTextRegionAccess().regionForEObject(eObject); | ||
148 | preprendNewLines(doc, region, init); | ||
149 | appendNewLines(doc, region, init); | ||
150 | } | ||
151 | |||
152 | protected void preprendNewLines(IFormattableDocument doc, ISequentialRegion region, | ||
153 | Procedure1<? super IHiddenRegionFormatter> init) { | ||
154 | if (region == null) { | ||
155 | return; | ||
156 | } | ||
157 | var previousHiddenRegion = region.getPreviousHiddenRegion(); | ||
158 | if (previousHiddenRegion == null) { | ||
159 | return; | ||
160 | } | ||
161 | if (previousHiddenRegion.getPreviousSequentialRegion() == null) { | ||
162 | doc.set(previousHiddenRegion, it -> it.setNewLines(0)); | ||
163 | } else { | ||
164 | doc.set(previousHiddenRegion, init); | ||
165 | } | ||
166 | } | ||
167 | |||
168 | protected void appendNewLines(IFormattableDocument doc, ISequentialRegion region, | ||
169 | Procedure1<? super IHiddenRegionFormatter> init) { | ||
170 | if (region == null) { | ||
171 | return; | ||
172 | } | ||
173 | var nextHiddenRegion = region.getNextHiddenRegion(); | ||
174 | if (nextHiddenRegion == null) { | ||
175 | return; | ||
176 | } | ||
177 | if (nextHiddenRegion.getNextSequentialRegion() == null) { | ||
178 | doc.set(nextHiddenRegion, it -> it.setNewLines(1)); | ||
179 | } else { | ||
180 | doc.set(nextHiddenRegion, init); | ||
181 | } | ||
182 | } | ||
183 | } | ||
diff --git a/language/src/test/java/tools/refinery/language/tests/formatting2/ProblemFormatterTest.java b/language/src/test/java/tools/refinery/language/tests/formatting2/ProblemFormatterTest.java new file mode 100644 index 00000000..41ad2d31 --- /dev/null +++ b/language/src/test/java/tools/refinery/language/tests/formatting2/ProblemFormatterTest.java | |||
@@ -0,0 +1,235 @@ | |||
1 | package tools.refinery.language.tests.formatting2; | ||
2 | |||
3 | import static org.hamcrest.MatcherAssert.assertThat; | ||
4 | import static org.hamcrest.Matchers.equalTo; | ||
5 | |||
6 | import java.util.List; | ||
7 | |||
8 | import org.eclipse.xtext.formatting2.FormatterRequest; | ||
9 | import org.eclipse.xtext.formatting2.IFormatter2; | ||
10 | import org.eclipse.xtext.formatting2.regionaccess.ITextRegionAccess; | ||
11 | import org.eclipse.xtext.formatting2.regionaccess.ITextReplacement; | ||
12 | import org.eclipse.xtext.formatting2.regionaccess.TextRegionAccessBuilder; | ||
13 | import org.eclipse.xtext.resource.XtextResource; | ||
14 | import org.eclipse.xtext.testing.InjectWith; | ||
15 | import org.eclipse.xtext.testing.extensions.InjectionExtension; | ||
16 | import org.eclipse.xtext.testing.util.ParseHelper; | ||
17 | import org.junit.jupiter.api.Test; | ||
18 | import org.junit.jupiter.api.extension.ExtendWith; | ||
19 | |||
20 | import com.google.inject.Inject; | ||
21 | import com.google.inject.Provider; | ||
22 | |||
23 | import tools.refinery.language.model.problem.Problem; | ||
24 | import tools.refinery.language.tests.ProblemInjectorProvider; | ||
25 | |||
26 | @ExtendWith(InjectionExtension.class) | ||
27 | @InjectWith(ProblemInjectorProvider.class) | ||
28 | class ProblemFormatterTest { | ||
29 | @Inject | ||
30 | private ParseHelper<Problem> parseHelper; | ||
31 | |||
32 | @Inject | ||
33 | private Provider<FormatterRequest> formatterRequestProvider; | ||
34 | |||
35 | @Inject | ||
36 | private TextRegionAccessBuilder regionBuilder; | ||
37 | |||
38 | @Inject | ||
39 | private IFormatter2 formatter2; | ||
40 | |||
41 | @Test | ||
42 | void problemNameTest() { | ||
43 | testFormatter(" problem problem . ", "problem problem.\n"); | ||
44 | } | ||
45 | |||
46 | @Test | ||
47 | void assertionTest() { | ||
48 | testFormatter(" equals ( a , b , * ) : true . ", "equals(a, b, *): true.\n"); | ||
49 | } | ||
50 | |||
51 | @Test | ||
52 | void defaultAssertionTest() { | ||
53 | testFormatter(" default equals ( a , b , * ) : true . ", "default equals(a, b, *): true.\n"); | ||
54 | } | ||
55 | |||
56 | @Test | ||
57 | void assertionShortTrueTest() { | ||
58 | testFormatter(" equals ( a , b , * ) . ", "equals(a, b, *).\n"); | ||
59 | } | ||
60 | |||
61 | @Test | ||
62 | void defaultAssertionShortTrueTest() { | ||
63 | testFormatter(" default equals ( a , b , * ) . ", "default equals(a, b, *).\n"); | ||
64 | } | ||
65 | |||
66 | @Test | ||
67 | void assertionShortFalseTest() { | ||
68 | testFormatter(" ! equals ( a , b , * ) . ", "!equals(a, b, *).\n"); | ||
69 | } | ||
70 | |||
71 | @Test | ||
72 | void defaultAssertionShortFalseTest() { | ||
73 | testFormatter(" default ! equals ( a , b , * ) . ", "default !equals(a, b, *).\n"); | ||
74 | } | ||
75 | |||
76 | @Test | ||
77 | void assertionShortUnknownTest() { | ||
78 | testFormatter(" ? equals ( a , b , * ) . ", "?equals(a, b, *).\n"); | ||
79 | } | ||
80 | |||
81 | @Test | ||
82 | void defaultAssertionShortUnknownTest() { | ||
83 | testFormatter(" default ? equals ( a , b , * ) . ", "default ?equals(a, b, *).\n"); | ||
84 | } | ||
85 | |||
86 | @Test | ||
87 | void multipleAssertionsTest() { | ||
88 | testFormatter(" exists ( a ) . ? equals ( a , a ).", """ | ||
89 | exists(a). | ||
90 | ?equals(a, a). | ||
91 | """); | ||
92 | } | ||
93 | |||
94 | @Test | ||
95 | void multipleAssertionsNamedProblemTest() { | ||
96 | testFormatter(" problem foo . exists ( a ) . ? equals ( a , a ).", """ | ||
97 | problem foo. | ||
98 | |||
99 | exists(a). | ||
100 | ?equals(a, a). | ||
101 | """); | ||
102 | } | ||
103 | |||
104 | @Test | ||
105 | void classWithoutBodyTest() { | ||
106 | testFormatter(" class Foo . ", "class Foo.\n"); | ||
107 | } | ||
108 | |||
109 | @Test | ||
110 | void abstractClassWithoutBodyTest() { | ||
111 | testFormatter(" abstract class Foo . ", "abstract class Foo.\n"); | ||
112 | } | ||
113 | |||
114 | @Test | ||
115 | void classExtendsWithoutBodyTest() { | ||
116 | testFormatter(" class Foo. class Bar . class Quux extends Foo , Bar . ", """ | ||
117 | class Foo. | ||
118 | |||
119 | class Bar. | ||
120 | |||
121 | class Quux extends Foo, Bar. | ||
122 | """); | ||
123 | } | ||
124 | |||
125 | @Test | ||
126 | void classWithEmptyBodyTest() { | ||
127 | testFormatter(" class Foo { } ", """ | ||
128 | class Foo { | ||
129 | } | ||
130 | """); | ||
131 | } | ||
132 | |||
133 | @Test | ||
134 | void classExtendsWithBodyTest() { | ||
135 | testFormatter(" class Foo. class Bar . class Quux extends Foo , Bar { } ", """ | ||
136 | class Foo. | ||
137 | |||
138 | class Bar. | ||
139 | |||
140 | class Quux extends Foo, Bar { | ||
141 | } | ||
142 | """); | ||
143 | } | ||
144 | |||
145 | @Test | ||
146 | void predicateWithoutBodyTest() { | ||
147 | testFormatter(" pred foo ( node a , b ) . ", "pred foo(node a, b).\n"); | ||
148 | } | ||
149 | |||
150 | @Test | ||
151 | void predicateWithBodyTest() { | ||
152 | testFormatter( | ||
153 | " pred foo ( node a , b ) <-> equal (a , _c ) , ! equal ( a , b ) ; equal+( a , b ) . ", | ||
154 | "pred foo(node a, b) <-> equal(a, _c), !equal(a, b); equal+(a, b).\n"); | ||
155 | } | ||
156 | |||
157 | @Test | ||
158 | void predicatesWithoutBodyTest() { | ||
159 | testFormatter(" pred foo ( node a , b ) . pred bar ( node c ) . ", """ | ||
160 | pred foo(node a, b). | ||
161 | |||
162 | pred bar(node c). | ||
163 | """); | ||
164 | } | ||
165 | |||
166 | @Test | ||
167 | void predicateCommentsTest() { | ||
168 | testFormatter(""" | ||
169 | % Some foo | ||
170 | pred foo ( node a , b ) . | ||
171 | % Some bar | ||
172 | pred bar ( node c ) . | ||
173 | """, """ | ||
174 | % Some foo | ||
175 | pred foo(node a, b). | ||
176 | |||
177 | % Some bar | ||
178 | pred bar(node c). | ||
179 | """); | ||
180 | } | ||
181 | |||
182 | @Test | ||
183 | void individualDeclarationTest() { | ||
184 | testFormatter(" indiv a , b . ", "indiv a, b.\n"); | ||
185 | } | ||
186 | |||
187 | @Test | ||
188 | void mixedDeclarationsTest() { | ||
189 | testFormatter(""" | ||
190 | problem test. | ||
191 | pred foo(node a). | ||
192 | class Foo. | ||
193 | foo(n1, n2). | ||
194 | indiv i1. | ||
195 | !foo(i1, n1). | ||
196 | pred bar(node a, node b). | ||
197 | pred quux(). | ||
198 | default !bar(*, *). | ||
199 | """, """ | ||
200 | problem test. | ||
201 | |||
202 | pred foo(node a). | ||
203 | |||
204 | class Foo. | ||
205 | |||
206 | foo(n1, n2). | ||
207 | indiv i1. | ||
208 | !foo(i1, n1). | ||
209 | |||
210 | pred bar(node a, node b). | ||
211 | |||
212 | pred quux(). | ||
213 | |||
214 | default !bar(*, *). | ||
215 | """); | ||
216 | } | ||
217 | |||
218 | private void testFormatter(String toFormat, String expected) { | ||
219 | Problem problem; | ||
220 | try { | ||
221 | problem = parseHelper.parse(toFormat); | ||
222 | } catch (Exception e) { | ||
223 | throw new RuntimeException("Failed to parse document", e); | ||
224 | } | ||
225 | var resource = (XtextResource) problem.eResource(); | ||
226 | FormatterRequest request = formatterRequestProvider.get(); | ||
227 | request.setAllowIdentityEdits(false); | ||
228 | request.setFormatUndefinedHiddenRegionsOnly(false); | ||
229 | ITextRegionAccess regionAccess = regionBuilder.forNodeModel(resource).create(); | ||
230 | request.setTextRegionAccess(regionAccess); | ||
231 | List<ITextReplacement> replacements = formatter2.format(request); | ||
232 | var formattedString = regionAccess.getRewriter().renderToString(replacements); | ||
233 | assertThat(formattedString, equalTo(expected)); | ||
234 | } | ||
235 | } | ||
diff --git a/language/src/test/java/tools/refinery/language/tests/serializer/ProblemSerializerTest.java b/language/src/test/java/tools/refinery/language/tests/serializer/ProblemSerializerTest.java index 22c79a09..ba3aaeb7 100644 --- a/language/src/test/java/tools/refinery/language/tests/serializer/ProblemSerializerTest.java +++ b/language/src/test/java/tools/refinery/language/tests/serializer/ProblemSerializerTest.java | |||
@@ -38,16 +38,16 @@ import tools.refinery.language.tests.ProblemInjectorProvider; | |||
38 | @InjectWith(ProblemInjectorProvider.class) | 38 | @InjectWith(ProblemInjectorProvider.class) |
39 | class ProblemSerializerTest { | 39 | class ProblemSerializerTest { |
40 | @Inject | 40 | @Inject |
41 | ResourceSet resourceSet; | 41 | private ResourceSet resourceSet; |
42 | 42 | ||
43 | @Inject | 43 | @Inject |
44 | ProblemTestUtil testUtil; | 44 | private ProblemTestUtil testUtil; |
45 | 45 | ||
46 | Resource resource; | 46 | private Resource resource; |
47 | 47 | ||
48 | Problem problem; | 48 | private Problem problem; |
49 | 49 | ||
50 | Problem builtin; | 50 | private Problem builtin; |
51 | 51 | ||
52 | @BeforeEach | 52 | @BeforeEach |
53 | void beforeEach() { | 53 | void beforeEach() { |
@@ -68,14 +68,16 @@ class ProblemSerializerTest { | |||
68 | problem.getStatements().add(individualDeclaration); | 68 | problem.getStatements().add(individualDeclaration); |
69 | createAssertion(pred, node, value); | 69 | createAssertion(pred, node, value); |
70 | 70 | ||
71 | assertSerializedResult("pred foo ( node p ) . indiv a . " + serializedAssertion); | 71 | assertSerializedResult(""" |
72 | pred foo(node p). | ||
73 | |||
74 | indiv a. | ||
75 | """ + serializedAssertion + "\n"); | ||
72 | } | 76 | } |
73 | 77 | ||
74 | static Stream<Arguments> assertionTest() { | 78 | static Stream<Arguments> assertionTest() { |
75 | return Stream.of(Arguments.of(LogicValue.TRUE, "foo ( a ) ."), | 79 | return Stream.of(Arguments.of(LogicValue.TRUE, "foo(a)."), Arguments.of(LogicValue.FALSE, "!foo(a)."), |
76 | Arguments.of(LogicValue.FALSE, "! foo ( a ) ."), | 80 | Arguments.of(LogicValue.UNKNOWN, "?foo(a)."), Arguments.of(LogicValue.ERROR, "foo(a): error.")); |
77 | Arguments.of(LogicValue.UNKNOWN, "? foo ( a ) ."), | ||
78 | Arguments.of(LogicValue.ERROR, "foo ( a ) : error .")); | ||
79 | } | 81 | } |
80 | 82 | ||
81 | @Test | 83 | @Test |
@@ -86,7 +88,11 @@ class ProblemSerializerTest { | |||
86 | problem.getNodes().add(node); | 88 | problem.getNodes().add(node); |
87 | createAssertion(pred, node); | 89 | createAssertion(pred, node); |
88 | 90 | ||
89 | assertSerializedResult("pred foo ( node p ) . foo ( a ) ."); | 91 | assertSerializedResult(""" |
92 | pred foo(node p). | ||
93 | |||
94 | foo(a). | ||
95 | """); | ||
90 | } | 96 | } |
91 | 97 | ||
92 | private PredicateDefinition createPred() { | 98 | private PredicateDefinition createPred() { |
@@ -111,7 +117,11 @@ class ProblemSerializerTest { | |||
111 | problem.getStatements().add(classDeclaration); | 117 | problem.getStatements().add(classDeclaration); |
112 | createAssertion(classDeclaration, newNode); | 118 | createAssertion(classDeclaration, newNode); |
113 | 119 | ||
114 | assertSerializedResult("class Foo . Foo ( Foo::new ) ."); | 120 | assertSerializedResult(""" |
121 | class Foo. | ||
122 | |||
123 | Foo(Foo::new). | ||
124 | """); | ||
115 | } | 125 | } |
116 | 126 | ||
117 | private void createAssertion(Relation relation, Node node) { | 127 | private void createAssertion(Relation relation, Node node) { |
@@ -151,7 +161,9 @@ class ProblemSerializerTest { | |||
151 | pred.getBodies().add(conjunction); | 161 | pred.getBodies().add(conjunction); |
152 | problem.getStatements().add(pred); | 162 | problem.getStatements().add(pred); |
153 | 163 | ||
154 | assertSerializedResult("pred foo ( node p1 , node p2 ) <-> equals ( p1 , q ) , equals ( q , p2 ) ."); | 164 | assertSerializedResult(""" |
165 | pred foo(node p1, node p2) <-> equals(p1, q), equals(q, p2). | ||
166 | """); | ||
155 | } | 167 | } |
156 | 168 | ||
157 | private Atom createAtom(Relation relation, VariableOrNode variable1, VariableOrNode variable2) { | 169 | private Atom createAtom(Relation relation, VariableOrNode variable1, VariableOrNode variable2) { |
@@ -192,7 +204,9 @@ class ProblemSerializerTest { | |||
192 | pred.getBodies().add(conjunction); | 204 | pred.getBodies().add(conjunction); |
193 | problem.getStatements().add(pred); | 205 | problem.getStatements().add(pred); |
194 | 206 | ||
195 | assertSerializedResult("pred foo ( node p ) <-> equals ( p , _q ) ."); | 207 | assertSerializedResult(""" |
208 | pred foo(node p) <-> equals(p, _q). | ||
209 | """); | ||
196 | } | 210 | } |
197 | 211 | ||
198 | private void assertSerializedResult(String expected) { | 212 | private void assertSerializedResult(String expected) { |