@platformos/codemirror-language-client 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +91 -0
  3. package/dist/esm/CodeMirrorLanguageClient.d.ts +60 -0
  4. package/dist/esm/CodeMirrorLanguageClient.js +80 -0
  5. package/dist/esm/CodeMirrorLanguageClient.js.map +1 -0
  6. package/dist/esm/LanguageClient.d.ts +112 -0
  7. package/dist/esm/LanguageClient.js +187 -0
  8. package/dist/esm/LanguageClient.js.map +1 -0
  9. package/dist/esm/extensions/client.d.ts +6 -0
  10. package/dist/esm/extensions/client.js +12 -0
  11. package/dist/esm/extensions/client.js.map +1 -0
  12. package/dist/esm/extensions/complete.d.ts +17 -0
  13. package/dist/esm/extensions/complete.js +202 -0
  14. package/dist/esm/extensions/complete.js.map +1 -0
  15. package/dist/esm/extensions/complete.spec.d.ts +1 -0
  16. package/dist/esm/extensions/complete.spec.js +189 -0
  17. package/dist/esm/extensions/complete.spec.js.map +1 -0
  18. package/dist/esm/extensions/documentHighlights.d.ts +14 -0
  19. package/dist/esm/extensions/documentHighlights.js +78 -0
  20. package/dist/esm/extensions/documentHighlights.js.map +1 -0
  21. package/dist/esm/extensions/documentHighlights.spec.d.ts +1 -0
  22. package/dist/esm/extensions/documentHighlights.spec.js +99 -0
  23. package/dist/esm/extensions/documentHighlights.spec.js.map +1 -0
  24. package/dist/esm/extensions/hover.d.ts +16 -0
  25. package/dist/esm/extensions/hover.js +49 -0
  26. package/dist/esm/extensions/hover.js.map +1 -0
  27. package/dist/esm/extensions/hover.spec.d.ts +1 -0
  28. package/dist/esm/extensions/hover.spec.js +59 -0
  29. package/dist/esm/extensions/hover.spec.js.map +1 -0
  30. package/dist/esm/extensions/index.d.ts +6 -0
  31. package/dist/esm/extensions/index.js +7 -0
  32. package/dist/esm/extensions/index.js.map +1 -0
  33. package/dist/esm/extensions/lspLinter.d.ts +23 -0
  34. package/dist/esm/extensions/lspLinter.js +104 -0
  35. package/dist/esm/extensions/lspLinter.js.map +1 -0
  36. package/dist/esm/extensions/lspLinter.spec.d.ts +1 -0
  37. package/dist/esm/extensions/lspLinter.spec.js +141 -0
  38. package/dist/esm/extensions/lspLinter.spec.js.map +1 -0
  39. package/dist/esm/extensions/snippet.d.ts +19 -0
  40. package/dist/esm/extensions/snippet.js +25 -0
  41. package/dist/esm/extensions/snippet.js.map +1 -0
  42. package/dist/esm/extensions/snippet.spec.d.ts +1 -0
  43. package/dist/esm/extensions/snippet.spec.js +23 -0
  44. package/dist/esm/extensions/snippet.spec.js.map +1 -0
  45. package/dist/esm/extensions/textDocumentSync.d.ts +4 -0
  46. package/dist/esm/extensions/textDocumentSync.js +109 -0
  47. package/dist/esm/extensions/textDocumentSync.js.map +1 -0
  48. package/dist/esm/extensions/textDocumentSync.spec.d.ts +1 -0
  49. package/dist/esm/extensions/textDocumentSync.spec.js +163 -0
  50. package/dist/esm/extensions/textDocumentSync.spec.js.map +1 -0
  51. package/dist/esm/index.d.ts +2 -0
  52. package/dist/esm/index.js +3 -0
  53. package/dist/esm/index.js.map +1 -0
  54. package/dist/esm/test/MockClient.d.ts +18 -0
  55. package/dist/esm/test/MockClient.js +63 -0
  56. package/dist/esm/test/MockClient.js.map +1 -0
  57. package/dist/esm/tsconfig.tsbuildInfo +1 -0
  58. package/dist/esm/utils/simpleStateField.d.ts +3 -0
  59. package/dist/esm/utils/simpleStateField.js +17 -0
  60. package/dist/esm/utils/simpleStateField.js.map +1 -0
  61. package/dist/umd/CodeMirrorLanguageClient.d.ts +60 -0
  62. package/dist/umd/CodeMirrorLanguageClient.js +94 -0
  63. package/dist/umd/CodeMirrorLanguageClient.js.map +1 -0
  64. package/dist/umd/LanguageClient.d.ts +112 -0
  65. package/dist/umd/LanguageClient.js +202 -0
  66. package/dist/umd/LanguageClient.js.map +1 -0
  67. package/dist/umd/extensions/client.d.ts +6 -0
  68. package/dist/umd/extensions/client.js +25 -0
  69. package/dist/umd/extensions/client.js.map +1 -0
  70. package/dist/umd/extensions/complete.d.ts +17 -0
  71. package/dist/umd/extensions/complete.js +217 -0
  72. package/dist/umd/extensions/complete.js.map +1 -0
  73. package/dist/umd/extensions/documentHighlights.d.ts +14 -0
  74. package/dist/umd/extensions/documentHighlights.js +93 -0
  75. package/dist/umd/extensions/documentHighlights.js.map +1 -0
  76. package/dist/umd/extensions/hover.d.ts +16 -0
  77. package/dist/umd/extensions/hover.js +64 -0
  78. package/dist/umd/extensions/hover.js.map +1 -0
  79. package/dist/umd/extensions/index.d.ts +6 -0
  80. package/dist/umd/extensions/index.js +36 -0
  81. package/dist/umd/extensions/index.js.map +1 -0
  82. package/dist/umd/extensions/lspLinter.d.ts +23 -0
  83. package/dist/umd/extensions/lspLinter.js +119 -0
  84. package/dist/umd/extensions/lspLinter.js.map +1 -0
  85. package/dist/umd/extensions/snippet.d.ts +19 -0
  86. package/dist/umd/extensions/snippet.js +38 -0
  87. package/dist/umd/extensions/snippet.js.map +1 -0
  88. package/dist/umd/extensions/textDocumentSync.d.ts +4 -0
  89. package/dist/umd/extensions/textDocumentSync.js +122 -0
  90. package/dist/umd/extensions/textDocumentSync.js.map +1 -0
  91. package/dist/umd/index.d.ts +2 -0
  92. package/dist/umd/index.js +29 -0
  93. package/dist/umd/index.js.map +1 -0
  94. package/dist/umd/test/MockClient.d.ts +18 -0
  95. package/dist/umd/test/MockClient.js +77 -0
  96. package/dist/umd/test/MockClient.js.map +1 -0
  97. package/dist/umd/tsconfig.tsbuildInfo +1 -0
  98. package/dist/umd/utils/simpleStateField.d.ts +3 -0
  99. package/dist/umd/utils/simpleStateField.js +30 -0
  100. package/dist/umd/utils/simpleStateField.js.map +1 -0
  101. package/package.json +67 -0
  102. package/playground/src/index.html +10 -0
  103. package/playground/src/language-server-worker.ts +82 -0
  104. package/playground/src/playground.ts +251 -0
  105. package/playground/tsconfig.json +26 -0
  106. package/playground/webpack.config.js +85 -0
  107. package/src/CodeMirrorLanguageClient.ts +179 -0
  108. package/src/LanguageClient.ts +329 -0
  109. package/src/extensions/client.ts +17 -0
  110. package/src/extensions/complete.spec.ts +200 -0
  111. package/src/extensions/complete.ts +274 -0
  112. package/src/extensions/documentHighlights.spec.ts +111 -0
  113. package/src/extensions/documentHighlights.ts +91 -0
  114. package/src/extensions/hover.spec.ts +68 -0
  115. package/src/extensions/hover.ts +66 -0
  116. package/src/extensions/index.ts +19 -0
  117. package/src/extensions/lspLinter.spec.ts +156 -0
  118. package/src/extensions/lspLinter.ts +154 -0
  119. package/src/extensions/snippet.spec.ts +31 -0
  120. package/src/extensions/snippet.ts +42 -0
  121. package/src/extensions/textDocumentSync.spec.ts +188 -0
  122. package/src/extensions/textDocumentSync.ts +138 -0
  123. package/src/index.ts +2 -0
  124. package/src/test/MockClient.ts +96 -0
  125. package/src/utils/simpleStateField.ts +22 -0
  126. package/tsconfig.json +18 -0
  127. package/tsconfig.umd.json +9 -0
@@ -0,0 +1,200 @@
1
+ import { vi, expect, describe, it, beforeEach, assert } from 'vitest';
2
+ import { EditorState, Extension } from '@codemirror/state';
3
+ import { CompletionContext } from '@codemirror/autocomplete';
4
+ import { clientFacet, fileUriFacet, serverCapabilitiesFacet } from './client';
5
+ import { MockClient } from '../test/MockClient';
6
+ import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver-protocol';
7
+ import { textDocumentSync } from './textDocumentSync';
8
+ import { complete, infoRendererFacet, lspComplete } from './complete';
9
+
10
+ describe('Module: complete', () => {
11
+ const fileUri = 'browser://input.liquid';
12
+ let client: MockClient;
13
+ let state: EditorState;
14
+ let infoRenderer: any;
15
+ let doc: string;
16
+
17
+ beforeEach(() => {
18
+ doc = 'hello. ;';
19
+ client = new MockClient();
20
+ infoRenderer = vi.fn().mockReturnValue(null);
21
+ state = EditorState.create({
22
+ doc,
23
+ extensions: [
24
+ clientFacet.of(client),
25
+ serverCapabilitiesFacet.of({
26
+ completionProvider: {
27
+ triggerCharacters: ['.'],
28
+ },
29
+ }),
30
+ fileUriFacet.of(fileUri),
31
+ textDocumentSync,
32
+ infoRendererFacet.of(infoRenderer),
33
+ lspComplete(),
34
+ ],
35
+ });
36
+ });
37
+
38
+ it('should translate LSP completion responses into CodeMirror completion items', async () => {
39
+ const context = new CompletionContext(state, /* does not matter for mock test */ 0, true);
40
+ const promise = complete(context);
41
+
42
+ client.resolveRequest([
43
+ {
44
+ label: 'hello | world',
45
+ insertText: 'hello',
46
+ kind: CompletionItemKind.Text,
47
+ },
48
+ {
49
+ label: 'echo',
50
+ kind: CompletionItemKind.Function,
51
+ documentation: {
52
+ kind: 'markdown',
53
+ value: '### echo',
54
+ },
55
+ },
56
+ {
57
+ label: '"general.greeting" | t',
58
+ insertText: 'greeting',
59
+ textEdit: {
60
+ newText: '"general.greeting" | t',
61
+ range: {
62
+ start: { line: 0, character: 0 },
63
+ end: { line: 0, character: 3 },
64
+ },
65
+ },
66
+ kind: CompletionItemKind.Function,
67
+ documentation: {
68
+ kind: 'markdown',
69
+ value: '### echo',
70
+ },
71
+ },
72
+ ] as CompletionItem[]);
73
+ const results = await promise;
74
+
75
+ expect(results).toEqual({
76
+ from: 0,
77
+ options: [
78
+ {
79
+ label: 'hello',
80
+ displayLabel: 'hello | world',
81
+ type: 'text',
82
+ info: expect.any(Function),
83
+ apply: undefined,
84
+ },
85
+ {
86
+ label: 'echo',
87
+ displayLabel: 'echo',
88
+ type: 'function',
89
+ info: expect.any(Function),
90
+ apply: undefined,
91
+ },
92
+ {
93
+ displayLabel: '"general.greeting" | t',
94
+ label: 'greeting',
95
+ type: 'function',
96
+ info: expect.any(Function),
97
+ apply: expect.any(Function),
98
+ },
99
+ ],
100
+ });
101
+ });
102
+
103
+ it('should translate LSP completion list responses into CodeMirror completion items', async () => {
104
+ const context = new CompletionContext(state, /* does not matter for mock test */ 0, true);
105
+ const promise = complete(context);
106
+
107
+ client.resolveRequest({
108
+ isIncomplete: false,
109
+ items: [
110
+ {
111
+ label: 'hello | world',
112
+ insertText: 'hello',
113
+ kind: CompletionItemKind.Text,
114
+ },
115
+ {
116
+ label: 'echo',
117
+ kind: CompletionItemKind.Function,
118
+ documentation: {
119
+ kind: 'markdown',
120
+ value: '### echo',
121
+ },
122
+ },
123
+ {
124
+ label: '"general.greeting" | t',
125
+ insertText: 'greeting',
126
+ textEdit: {
127
+ newText: '"general.greeting" | t',
128
+ range: {
129
+ start: { line: 0, character: 0 },
130
+ end: { line: 0, character: 3 },
131
+ },
132
+ },
133
+ kind: CompletionItemKind.Function,
134
+ documentation: {
135
+ kind: 'markdown',
136
+ value: '### echo',
137
+ },
138
+ },
139
+ ],
140
+ } as CompletionList);
141
+ const results = await promise;
142
+
143
+ expect(results).toEqual({
144
+ from: 0,
145
+ options: [
146
+ {
147
+ label: 'hello',
148
+ displayLabel: 'hello | world',
149
+ type: 'text',
150
+ info: expect.any(Function),
151
+ apply: undefined,
152
+ },
153
+ {
154
+ label: 'echo',
155
+ displayLabel: 'echo',
156
+ type: 'function',
157
+ info: expect.any(Function),
158
+ apply: undefined,
159
+ },
160
+ {
161
+ displayLabel: '"general.greeting" | t',
162
+ label: 'greeting',
163
+ type: 'function',
164
+ info: expect.any(Function),
165
+ apply: expect.any(Function),
166
+ },
167
+ ],
168
+ });
169
+ });
170
+
171
+ it('should return null if not on a trigger character nor inside a word', async () => {
172
+ const context = new CompletionContext(state, doc.indexOf(';'), true);
173
+ const promise = complete(context);
174
+ assert(!client.pendingRequest);
175
+ const result = await promise;
176
+ expect(result).to.be.null;
177
+ });
178
+
179
+ it('should make a request to the backend if after a trigger character', async () => {
180
+ const context = new CompletionContext(state, doc.indexOf('.') + 1, true);
181
+ complete(context);
182
+ assert(client.pendingRequest);
183
+ client.resolveRequest(null);
184
+ });
185
+
186
+ it('should make a request to the backend if after a word character', async () => {
187
+ const context = new CompletionContext(state, doc.indexOf('h') + 1, true);
188
+ complete(context);
189
+ assert(client.pendingRequest);
190
+ client.resolveRequest(null);
191
+ });
192
+
193
+ it('should return null if nothing comes back', async () => {
194
+ const context = new CompletionContext(state, /* does not matter for mock test */ 0, true);
195
+ const promise = complete(context);
196
+ client.resolveRequest(null);
197
+ const results = await promise;
198
+ expect(results).to.eql(null);
199
+ });
200
+ });
@@ -0,0 +1,274 @@
1
+ import {
2
+ autocompletion,
3
+ CompletionContext,
4
+ CompletionResult,
5
+ CompletionInfo,
6
+ pickedCompletion,
7
+ Completion,
8
+ snippet,
9
+ } from '@codemirror/autocomplete';
10
+ import {
11
+ CompletionItem,
12
+ CompletionItemKind,
13
+ CompletionList,
14
+ CompletionRequest,
15
+ InsertTextFormat,
16
+ InsertReplaceEdit,
17
+ TextEdit,
18
+ CompletionTriggerKind,
19
+ CompletionContext as LSPCompletionContext,
20
+ } from 'vscode-languageserver-protocol';
21
+ import { clientFacet, fileUriFacet, serverCapabilitiesFacet } from './client';
22
+ import { textDocumentField } from './textDocumentSync';
23
+ import { Facet } from '@codemirror/state';
24
+ import { EditorView } from '@codemirror/view';
25
+ import { TextDocument } from 'vscode-languageserver-textdocument';
26
+ import { translateSnippet } from './snippet';
27
+
28
+ type FirstArgType<F> = F extends (arg: infer A) => any ? A : never;
29
+ export type AutocompleteOptions = Partial<FirstArgType<typeof autocompletion>>;
30
+
31
+ export const lspComplete = (overrides: AutocompleteOptions = {}) =>
32
+ autocompletion({
33
+ activateOnTyping: true,
34
+ override: [complete],
35
+ maxRenderedOptions: 20,
36
+ ...overrides,
37
+ });
38
+
39
+ /**
40
+ * An InfoRenderer would be equivalent to the Quick Info window in VS Code. It's the part of the completion
41
+ * window that shows you the docs about the thing that is currently selected.
42
+ *
43
+ * Takes a LSP CompletionItem as argument and returns a DOM node, optional and injected so that we can control
44
+ * how we render the info window from the rendering context.
45
+ */
46
+ export type InfoRenderer = (completion: CompletionItem) => CompletionInfo;
47
+
48
+ export const infoRendererFacet = Facet.define<InfoRenderer | undefined, InfoRenderer | undefined>({
49
+ static: true,
50
+ combine: (values) => values[0] ?? undefined,
51
+ });
52
+
53
+ export async function complete(context: CompletionContext): Promise<CompletionResult | null> {
54
+ const word = context.matchBefore(/\w+/);
55
+ const serverCapabilities = context.state.facet(serverCapabilitiesFacet.reader);
56
+ const triggerCharacters = serverCapabilities.completionProvider?.triggerCharacters ?? [];
57
+ const previousChar = context.state.doc.sliceString(context.pos - 1, context.pos);
58
+ const isTriggerCharacter = triggerCharacters.includes(previousChar);
59
+ const canComplete = isTriggerCharacter || word || context.pos === 0;
60
+ if (!canComplete) return null;
61
+
62
+ const client = context.state.facet(clientFacet.reader);
63
+ const fileUri = context.state.facet(fileUriFacet.reader);
64
+ const infoRenderer = context.state.facet(infoRendererFacet.reader);
65
+ const textDocument = context.state.field(textDocumentField);
66
+
67
+ const lspContext: LSPCompletionContext = isTriggerCharacter
68
+ ? { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter: previousChar }
69
+ : { triggerKind: CompletionTriggerKind.Invoked };
70
+
71
+ const results = await client.sendRequest(CompletionRequest.type, {
72
+ textDocument: { uri: fileUri },
73
+ position: textDocument.positionAt(context.pos),
74
+ context: lspContext,
75
+ });
76
+
77
+ // No results
78
+ if (results === null || (Array.isArray(results) && results.length === 0)) return null;
79
+
80
+ return {
81
+ from: word?.from ?? context.pos,
82
+ options: items(results).map(toCodeMirrorCompletion(infoRenderer, textDocument)),
83
+ };
84
+ }
85
+
86
+ const toCodeMirrorCompletion =
87
+ (infoRenderer: InfoRenderer | undefined, textDocument: TextDocument) =>
88
+ (completionItem: CompletionItem): Completion => {
89
+ switch (completionItem.insertTextFormat) {
90
+ case InsertTextFormat.Snippet:
91
+ return {
92
+ label: completionItem.insertText ?? completionItem.label,
93
+ displayLabel: completionItem.label,
94
+ apply: applySnippet(completionItem as SnippetCompletionItem, textDocument),
95
+ type: convertLSPKindToCodeMirrorKind(completionItem.kind),
96
+ info: infoRenderer ? (_) => infoRenderer(completionItem) : undefined,
97
+ };
98
+
99
+ case InsertTextFormat.PlainText:
100
+ default: {
101
+ return {
102
+ label: completionItem.insertText ?? completionItem.label,
103
+ displayLabel: completionItem.label,
104
+ apply: hasApplicableTextEdit(completionItem)
105
+ ? (view, completion) => applyEdit(view, completion, completionItem, textDocument)
106
+ : undefined,
107
+ type: convertLSPKindToCodeMirrorKind(completionItem.kind),
108
+ info: infoRenderer ? (_) => infoRenderer(completionItem) : undefined,
109
+ };
110
+ }
111
+ }
112
+ };
113
+
114
+ function hasApplicableTextEdit(
115
+ completionItem: CompletionItem,
116
+ ): completionItem is CompletionItem & { textEdit: NonNullable<CompletionItem['textEdit']> } {
117
+ return (
118
+ !!completionItem.textEdit &&
119
+ (TextEdit.is(completionItem.textEdit) || InsertReplaceEdit.is(completionItem.textEdit))
120
+ );
121
+ }
122
+
123
+ type SnippetCompletionItem = Omit<CompletionItem, 'insertTextFormat'> & {
124
+ insertTextFormat: typeof InsertTextFormat.Snippet;
125
+ };
126
+
127
+ const applySnippet = (item: SnippetCompletionItem, textDocument: TextDocument) => {
128
+ const { textEdit } = item;
129
+ let from: null | number = null;
130
+ let to: null | number = null;
131
+ let newText = '';
132
+
133
+ if (TextEdit.is(textEdit)) {
134
+ from = textDocument.offsetAt(textEdit.range.start);
135
+ to = textDocument.offsetAt(textEdit.range.end);
136
+ newText = textEdit.newText;
137
+ } else if (textEdit && InsertReplaceEdit.is(textEdit)) {
138
+ from = textDocument.offsetAt(textEdit.replace.start);
139
+ to = textDocument.offsetAt(textEdit.replace.end);
140
+ newText = textEdit.newText;
141
+ } else if (item.insertText) {
142
+ newText = item.insertText;
143
+ } else {
144
+ newText = item.label;
145
+ }
146
+
147
+ const template = translateSnippet(newText);
148
+
149
+ // Because we might replace text with textEdit, we can't use snippet as is.
150
+ // we'll need to infer the from/to from the textEdit.
151
+ const apply = snippet(template);
152
+ return (view: EditorView, completion: Completion, defaultFrom: number, defaultTo: number) => {
153
+ apply(view, completion, from ?? defaultFrom, to ?? defaultTo);
154
+ };
155
+ };
156
+
157
+ enum CMCompletionType {
158
+ Class = 'class',
159
+ Constant = 'constant',
160
+ Enum = 'enum',
161
+ Function = 'function',
162
+ Interface = 'interface',
163
+ Keyword = 'keyword',
164
+ Method = 'method',
165
+ Namespace = 'namespace',
166
+ Property = 'property',
167
+ Text = 'text',
168
+ Type = 'type',
169
+ Variable = 'variable',
170
+ }
171
+
172
+ function convertLSPKindToCodeMirrorKind(kind: CompletionItemKind | undefined): CMCompletionType {
173
+ if (!kind) return CMCompletionType.Text;
174
+ switch (kind) {
175
+ case CompletionItemKind.Constructor:
176
+ case CompletionItemKind.Class:
177
+ return CMCompletionType.Class;
178
+
179
+ case CompletionItemKind.Constant:
180
+ return CMCompletionType.Constant;
181
+
182
+ case CompletionItemKind.Enum:
183
+ case CompletionItemKind.EnumMember:
184
+ return CMCompletionType.Enum;
185
+
186
+ case CompletionItemKind.Snippet:
187
+ case CompletionItemKind.Function:
188
+ return CMCompletionType.Function;
189
+
190
+ case CompletionItemKind.Interface:
191
+ return CMCompletionType.Interface;
192
+
193
+ case CompletionItemKind.Operator:
194
+ case CompletionItemKind.Keyword:
195
+ return CMCompletionType.Keyword;
196
+
197
+ case CompletionItemKind.Method:
198
+ return CMCompletionType.Method;
199
+
200
+ case CompletionItemKind.File:
201
+ case CompletionItemKind.Folder:
202
+ case CompletionItemKind.Module:
203
+ return CMCompletionType.Namespace;
204
+
205
+ case CompletionItemKind.Property:
206
+ case CompletionItemKind.Reference:
207
+ case CompletionItemKind.Field:
208
+ return CMCompletionType.Property;
209
+
210
+ case CompletionItemKind.Struct:
211
+ case CompletionItemKind.TypeParameter:
212
+ return CMCompletionType.Type;
213
+
214
+ case CompletionItemKind.Event:
215
+ case CompletionItemKind.Value:
216
+ case CompletionItemKind.Variable:
217
+ return CMCompletionType.Variable;
218
+
219
+ case CompletionItemKind.Color:
220
+ case CompletionItemKind.Unit:
221
+ case CompletionItemKind.Text:
222
+ default:
223
+ return CMCompletionType.Text;
224
+ }
225
+ }
226
+
227
+ function applyEdit(
228
+ view: EditorView,
229
+ completion: Completion,
230
+ item: CompletionItem & { textEdit: NonNullable<CompletionItem['textEdit']> },
231
+ textDocument: TextDocument,
232
+ ) {
233
+ const { textEdit } = item;
234
+ let start = 0;
235
+ let end = 0;
236
+ let newText = '';
237
+
238
+ if (TextEdit.is(textEdit)) {
239
+ start = textDocument.offsetAt(textEdit.range.start);
240
+ end = textDocument.offsetAt(textEdit.range.end);
241
+ newText = textEdit.newText;
242
+ } else if (InsertReplaceEdit.is(textEdit)) {
243
+ start = textDocument.offsetAt(textEdit.replace.start);
244
+ end = textDocument.offsetAt(textEdit.replace.end);
245
+ newText = textEdit.newText;
246
+ }
247
+
248
+ view.dispatch({
249
+ // Tell the completion engine which item we chose
250
+ annotations: [pickedCompletion.of(completion)],
251
+ // Move cursor to after the text
252
+ selection: { anchor: start + newText.length, head: start + newText.length },
253
+ // Apply the text edit
254
+ changes: view.state.changes({
255
+ from: start,
256
+ to: end,
257
+ insert: newText,
258
+ }),
259
+ });
260
+ }
261
+
262
+ function isCompletionList(results: CompletionList | CompletionItem[]): results is CompletionList {
263
+ return (results as CompletionList).isIncomplete !== undefined;
264
+ }
265
+
266
+ function items(results: CompletionList | CompletionItem[]): CompletionItem[] {
267
+ if (isCompletionList(results)) {
268
+ return (results as CompletionList).items.map((item) => ({
269
+ ...results.itemDefaults,
270
+ ...item,
271
+ }));
272
+ }
273
+ return results;
274
+ }
@@ -0,0 +1,111 @@
1
+ // @vitest-environment jsdom
2
+ import { EditorState, Extension, Range, RangeSet, RangeValue } from '@codemirror/state';
3
+ import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
4
+ import { assert, beforeEach, describe, expect, it } from 'vitest';
5
+ import { DocumentHighlight } from 'vscode-languageserver-protocol';
6
+ import { MockClient } from '../test/MockClient';
7
+ import { clientFacet, fileUriFacet } from './client';
8
+ import { getDecorations, highlightDeco, lspDocumentHighlights } from './documentHighlights';
9
+ import { textDocumentSync } from './textDocumentSync';
10
+
11
+ describe('Module: documentHighlights', () => {
12
+ const fileUri = 'browser://input.liquid';
13
+ let client: MockClient;
14
+ let extensions: Extension;
15
+ let view: EditorView;
16
+ let hoverRenderer: any;
17
+
18
+ beforeEach(() => {
19
+ client = new MockClient({}, { documentHighlightProvider: true });
20
+ extensions = [
21
+ clientFacet.of(client),
22
+ fileUriFacet.of(fileUri),
23
+ textDocumentSync,
24
+ lspDocumentHighlights(),
25
+ ];
26
+ view = new EditorView({
27
+ state: EditorState.create({
28
+ doc: 'hello world',
29
+ extensions,
30
+ }),
31
+ });
32
+ });
33
+
34
+ it('should translate LSP Document Highlights into a CodeMirror DecorationSet', async () => {
35
+ const promise = getDecorations(view);
36
+ const highlightsResponse: DocumentHighlight[] = [
37
+ {
38
+ range: {
39
+ start: { line: 0, character: 0 },
40
+ end: { line: 0, character: 5 },
41
+ },
42
+ },
43
+ ];
44
+
45
+ client.resolveRequest(highlightsResponse);
46
+ const result = await promise;
47
+ const actual = toArray(result);
48
+ const expected = toArray(Decoration.set([highlightDeco.range(0, 5)]));
49
+ expect(actual).to.eql(expected);
50
+ });
51
+
52
+ it('should sort the decorations', async () => {
53
+ const promise = getDecorations(view);
54
+ const highlightsResponse: DocumentHighlight[] = [
55
+ {
56
+ range: {
57
+ start: { line: 0, character: 5 },
58
+ end: { line: 0, character: 10 },
59
+ },
60
+ },
61
+ {
62
+ range: {
63
+ start: { line: 0, character: 0 },
64
+ end: { line: 0, character: 5 },
65
+ },
66
+ },
67
+ ];
68
+
69
+ client.resolveRequest(highlightsResponse);
70
+ const result = await promise;
71
+ expect(toArray(result)).to.eql(
72
+ toArray(Decoration.set([highlightDeco.range(0, 5), highlightDeco.range(5, 10)])),
73
+ );
74
+ });
75
+
76
+ it('should return a Decoration.none DecorationSet', async () => {
77
+ const promise = getDecorations(view);
78
+ client.resolveRequest(null);
79
+ const result = await promise;
80
+ expect(toArray(result)).to.eql(toArray(Decoration.none));
81
+ });
82
+
83
+ /**
84
+ * I wish CM didn't think that their implementation of iterators was better
85
+ * than the built-in one. Makes testing that stuff really hard for what seems
86
+ * like an obscure reason.
87
+ *
88
+ * > A range cursor is an object that moves to the next range every time you
89
+ * > call next on it. Note that, unlike ES6 iterators, these start out pointing
90
+ * > at the first element, so you should call next only after reading the first
91
+ * > range (if any).
92
+ *
93
+ * Also, RangeSet.eq doesn't seem to work. Which is why I decided to take this
94
+ * approach.
95
+ */
96
+ function* toIterator(decoSet: DecorationSet): Iterable<Range<Decoration>> {
97
+ let iter = decoSet.iter();
98
+ if (iter.value === null) {
99
+ return;
100
+ }
101
+
102
+ do {
103
+ yield { from: iter.from, to: iter.to, value: iter.value };
104
+ iter.next();
105
+ } while (iter.value !== null);
106
+ }
107
+
108
+ function toArray(decoSet: DecorationSet): Range<Decoration>[] {
109
+ return Array.from(toIterator(decoSet));
110
+ }
111
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * This extension requests LSP Document Highlights [1] when the user types or
3
+ * change its code selection.
4
+ *
5
+ * Those are then transformed into CodeMirror decorations and applied to the editor.
6
+ *
7
+ * [1]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentHighlight
8
+ */
9
+ import { Extension, Prec, StateEffect, StateField } from '@codemirror/state';
10
+ import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
11
+ import { DocumentHighlightRequest } from 'vscode-languageserver-protocol';
12
+ import { clientFacet, fileUriFacet } from './client';
13
+ import { textDocumentField } from './textDocumentSync';
14
+
15
+ // We will use this CSS class to decorate all document highlights
16
+ export const documentHighlightsClass = 'cmlc-document-highlights';
17
+ export const highlightDeco = Decoration.mark({ class: documentHighlightsClass });
18
+ const highlightStyles = Prec.low(
19
+ EditorView.theme({
20
+ [`.${documentHighlightsClass}`]: {
21
+ // Set background color to a light gray that matches a subtle highlight on
22
+ // a light theme
23
+ 'background-color': 'rgba(0,0,0,0.1)',
24
+ },
25
+ }),
26
+ );
27
+
28
+ const setDocumentHighlights = StateEffect.define<DecorationSet>();
29
+ const documentHighlightsStateField = StateField.define<DecorationSet>({
30
+ create: () => Decoration.none,
31
+ update(value, tr) {
32
+ let updatedValue = value;
33
+
34
+ for (const effect of tr.effects) {
35
+ if (effect.is(setDocumentHighlights)) {
36
+ updatedValue = effect.value;
37
+ }
38
+ }
39
+
40
+ return updatedValue;
41
+ },
42
+ // This is some CM obscure API shit that registers this state field
43
+ // into the CM state and makes it so CM will show the decorations
44
+ provide: (f) => EditorView.decorations.from(f),
45
+ });
46
+
47
+ export async function getDecorations(view: EditorView): Promise<DecorationSet> {
48
+ const client = view.state.facet(clientFacet.reader);
49
+ const uri = view.state.facet(fileUriFacet.reader);
50
+ const textDocument = view.state.field(textDocumentField);
51
+
52
+ // If the client doesn't support those requests, don't ask for them.
53
+ if (!client.serverCapabilities?.documentHighlightProvider) {
54
+ return Decoration.none;
55
+ }
56
+
57
+ const results = await client.sendRequest(DocumentHighlightRequest.type, {
58
+ textDocument: { uri },
59
+ position: textDocument.positionAt(view.state.selection.main.from),
60
+ });
61
+
62
+ if (!results) {
63
+ return Decoration.none;
64
+ }
65
+
66
+ const decorations = results
67
+ .map((highlight) => ({
68
+ from: textDocument.offsetAt(highlight.range.start),
69
+ to: textDocument.offsetAt(highlight.range.end),
70
+ }))
71
+ .sort((a, b) => a.from - b.from) // CM wants them sorted ascending or else it freaks out
72
+ .map(({ from, to }) => highlightDeco.range(from, to));
73
+
74
+ return Decoration.set(decorations);
75
+ }
76
+
77
+ export function lspDocumentHighlights(): Extension {
78
+ const decorationHandler = EditorView.updateListener.of((update) => {
79
+ // We only want to query the language server for document highlights
80
+ // the document has changed or the selection has moved
81
+ if (update.docChanged || update.selectionSet) {
82
+ getDecorations(update.view).then((deco) => {
83
+ update.view.dispatch({
84
+ effects: setDocumentHighlights.of(deco),
85
+ });
86
+ });
87
+ }
88
+ });
89
+
90
+ return [documentHighlightsStateField, decorationHandler, highlightStyles];
91
+ }