zed-ets-language-server 2.1.0 → 2.2.0

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.
package/index.js CHANGED
@@ -82,6 +82,43 @@ async function main() {
82
82
  serverProcess.send(etsSpecialRequest);
83
83
  return;
84
84
  }
85
+
86
+ // Forward standard formatting requests to custom ets/formatDocument
87
+ if (message.method === 'textDocument/formatting') {
88
+ const etsFormatRequest = {
89
+ jsonrpc: message.jsonrpc,
90
+ id: message.id,
91
+ method: 'ets/formatDocument',
92
+ params: {
93
+ textDocument: message.params.textDocument,
94
+ options: message.params.options,
95
+ },
96
+ };
97
+
98
+ logger.info(`Forwarding formatting request to ets/formatDocument: ${JSON.stringify(etsFormatRequest)}`);
99
+ serverProcess.send(etsFormatRequest);
100
+ return;
101
+ }
102
+
103
+ // Forward range formatting requests to custom ets/formatDocument
104
+ // Note: ets/formatDocument doesn't support range formatting explicitly,
105
+ // so we forward it as a full document format request
106
+ if (message.method === 'textDocument/rangeFormatting') {
107
+ const etsFormatRequest = {
108
+ jsonrpc: message.jsonrpc,
109
+ id: message.id,
110
+ method: 'ets/formatDocument',
111
+ params: {
112
+ textDocument: message.params.textDocument,
113
+ options: message.params.options,
114
+ },
115
+ };
116
+
117
+ logger.info(`Forwarding range formatting request to ets/formatDocument: ${JSON.stringify(etsFormatRequest)}`);
118
+ serverProcess.send(etsFormatRequest);
119
+ return;
120
+ }
121
+
85
122
  // Send message to language server via IPC
86
123
  serverProcess.send(message);
87
124
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zed-ets-language-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "ETS language server wrapper for Zed ArkTS extension.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,11 @@
8
8
  "node": ">=22.12.0"
9
9
  },
10
10
  "scripts": {
11
- "test": "vitest run"
11
+ "test": "vitest run",
12
+ "test:unit": "vitest run lib/",
13
+ "test:integration": "vitest run tests/integration/",
14
+ "test:formatting": "vitest run tests/integration/formatting.test.js tests/integration/formatting-content.test.js",
15
+ "test:watch": "vitest"
12
16
  },
13
17
  "author": "liuyanghejerry <liuyanghejerry@126.com>",
14
18
  "license": "MIT",
@@ -0,0 +1,21 @@
1
+ // Formatted ArkTS code
2
+ @Entry
3
+ @Component
4
+ struct Index {
5
+ @State message: string = 'Hello World'
6
+ @State counter: number = 0
7
+
8
+ build() {
9
+ Column({ space: 20 }) {
10
+ Text(this.message)
11
+ .fontSize(30)
12
+ .fontWeight(FontWeight.Bold)
13
+
14
+ Button('Click')
15
+ .onClick(() => {
16
+ this.counter++
17
+ })
18
+ }
19
+ .width('100%')
20
+ }
21
+ }
@@ -0,0 +1,17 @@
1
+ // Unformatted ArkTS code
2
+ @Entry
3
+ @Component
4
+ struct Index {
5
+ @State message:string='Hello World'
6
+ @State counter:number=0
7
+
8
+ build() {
9
+ Column({space:20}) {
10
+ Text(this.message).fontSize(30).fontWeight(FontWeight.Bold)
11
+ Button('Click').onClick(()=>{
12
+ this.counter++
13
+ })
14
+ }
15
+ .width('100%')
16
+ }
17
+ }
@@ -0,0 +1,472 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { spawn } from 'child_process';
3
+ import { readFileSync } from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ /**
11
+ * 创建 LSP 消息
12
+ */
13
+ function createLSPMessage(content) {
14
+ const json = JSON.stringify(content);
15
+ const contentLength = Buffer.byteLength(json, 'utf8');
16
+ return `Content-Length: ${contentLength}\r\n\r\n${json}`;
17
+ }
18
+
19
+ /**
20
+ * 解析 LSP 响应
21
+ */
22
+ function parseLSPResponse(data) {
23
+ const text = data.toString();
24
+ const match = text.match(/Content-Length: (\d+)\r\n\r\n(.*)/s);
25
+ if (!match) return null;
26
+
27
+ try {
28
+ return JSON.parse(match[2]);
29
+ } catch (e) {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 等待特定响应
36
+ */
37
+ function waitForResponse(responses, predicate, timeout = 3000) {
38
+ return new Promise((resolve, reject) => {
39
+ const startTime = Date.now();
40
+ const checkInterval = setInterval(() => {
41
+ const response = responses.find(predicate);
42
+ if (response) {
43
+ clearInterval(checkInterval);
44
+ resolve(response);
45
+ } else if (Date.now() - startTime > timeout) {
46
+ clearInterval(checkInterval);
47
+ reject(new Error('Timeout waiting for response'));
48
+ }
49
+ }, 100);
50
+ });
51
+ }
52
+
53
+ /**
54
+ * 应用 TextEdit 到文本内容
55
+ */
56
+ function applyTextEdits(originalText, textEdits) {
57
+ if (!textEdits || textEdits.length === 0) {
58
+ return originalText;
59
+ }
60
+
61
+ // 将文本分割成行
62
+ const lines = originalText.split('\n');
63
+
64
+ // 按照逆序应用编辑(从后往前),避免位置偏移问题
65
+ const sortedEdits = [...textEdits].sort((a, b) => {
66
+ if (a.range.start.line !== b.range.start.line) {
67
+ return b.range.start.line - a.range.start.line;
68
+ }
69
+ return b.range.start.character - a.range.start.character;
70
+ });
71
+
72
+ for (const edit of sortedEdits) {
73
+ const { range, newText } = edit;
74
+ const { start, end } = range;
75
+
76
+ // 提取需要替换的部分
77
+ const startLine = lines[start.line] || '';
78
+ const endLine = lines[end.line] || '';
79
+
80
+ const before = startLine.substring(0, start.character);
81
+ const after = endLine.substring(end.character);
82
+
83
+ // 删除被替换的行
84
+ lines.splice(start.line, end.line - start.line + 1);
85
+
86
+ // 插入新文本
87
+ const newLines = (before + newText + after).split('\n');
88
+ lines.splice(start.line, 0, ...newLines);
89
+ }
90
+
91
+ return lines.join('\n');
92
+ }
93
+
94
+ describe('Code Formatting Content Validation Tests', () => {
95
+ let serverProcess;
96
+ let responses = [];
97
+ let messageId = 1;
98
+ const fixturesDir = join(__dirname, '../fixtures');
99
+
100
+ beforeAll(() => {
101
+ // 启动 LSP 服务器
102
+ const serverPath = join(__dirname, '../../index.js');
103
+
104
+ // 设置环境变量,指向模拟的 ETS 语言服务器
105
+ const env = {
106
+ ...process.env,
107
+ ETS_LANG_SERVER: join(__dirname, '../mocks/mock-ets-server.js')
108
+ };
109
+
110
+ serverProcess = spawn('node', [serverPath], {
111
+ stdio: ['pipe', 'pipe', 'pipe'],
112
+ env
113
+ });
114
+
115
+ // 收集响应
116
+ serverProcess.stdout.on('data', (data) => {
117
+ const response = parseLSPResponse(data);
118
+ if (response) {
119
+ responses.push(response);
120
+ }
121
+ });
122
+
123
+ serverProcess.stderr.on('data', (data) => {
124
+ console.error(`LSP Server Error: ${data}`);
125
+ });
126
+ });
127
+
128
+ afterAll(() => {
129
+ if (serverProcess) {
130
+ serverProcess.kill();
131
+ }
132
+ });
133
+
134
+ it('should format unformatted code correctly', async () => {
135
+ const unformattedContent = readFileSync(
136
+ join(fixturesDir, 'unformatted.ets'),
137
+ 'utf8'
138
+ );
139
+ const expectedFormattedContent = readFileSync(
140
+ join(fixturesDir, 'formatted.ets'),
141
+ 'utf8'
142
+ );
143
+
144
+ const currentId = messageId++;
145
+ const formattingRequest = {
146
+ jsonrpc: '2.0',
147
+ id: currentId,
148
+ method: 'textDocument/formatting',
149
+ params: {
150
+ textDocument: {
151
+ uri: 'file:///unformatted.ets'
152
+ },
153
+ options: {
154
+ tabSize: 2,
155
+ insertSpaces: true
156
+ }
157
+ }
158
+ };
159
+
160
+ const message = createLSPMessage(formattingRequest);
161
+ serverProcess.stdin.write(message);
162
+
163
+ // 等待格式化响应
164
+ const response = await waitForResponse(
165
+ responses,
166
+ r => r.id === currentId
167
+ );
168
+
169
+ expect(response).toBeDefined();
170
+ expect(response.result).toBeDefined();
171
+ expect(Array.isArray(response.result)).toBe(true);
172
+ expect(response.result.length).toBeGreaterThan(0);
173
+
174
+ // 验证 TextEdit 结构
175
+ const textEdit = response.result[0];
176
+ expect(textEdit).toHaveProperty('range');
177
+ expect(textEdit).toHaveProperty('newText');
178
+ expect(textEdit.range).toHaveProperty('start');
179
+ expect(textEdit.range).toHaveProperty('end');
180
+
181
+ // 应用 TextEdit 并验证结果
182
+ const formattedContent = applyTextEdits(unformattedContent, response.result);
183
+
184
+ // 验证格式化后的内容与预期一致
185
+ expect(formattedContent.trim()).toBe(expectedFormattedContent.trim());
186
+ });
187
+
188
+ it('should return proper TextEdit for formatting', async () => {
189
+ const currentId = messageId++;
190
+ const formattingRequest = {
191
+ jsonrpc: '2.0',
192
+ id: currentId,
193
+ method: 'textDocument/formatting',
194
+ params: {
195
+ textDocument: {
196
+ uri: 'file:///test.ets'
197
+ },
198
+ options: {
199
+ tabSize: 2,
200
+ insertSpaces: true
201
+ }
202
+ }
203
+ };
204
+
205
+ const message = createLSPMessage(formattingRequest);
206
+ serverProcess.stdin.write(message);
207
+
208
+ const response = await waitForResponse(
209
+ responses,
210
+ r => r.id === currentId
211
+ );
212
+
213
+ expect(response).toBeDefined();
214
+ expect(response.result).toBeDefined();
215
+ expect(Array.isArray(response.result)).toBe(true);
216
+
217
+ // 验证返回的 TextEdit 包含有效的格式化内容
218
+ const textEdit = response.result[0];
219
+ expect(textEdit.newText).toBeDefined();
220
+ expect(textEdit.newText.length).toBeGreaterThan(0);
221
+
222
+ // 验证格式化内容包含正确的结构
223
+ expect(textEdit.newText).toContain('@Entry');
224
+ expect(textEdit.newText).toContain('@Component');
225
+ expect(textEdit.newText).toContain('struct');
226
+ });
227
+
228
+ it('should preserve indentation with insertSpaces option', async () => {
229
+ const currentId = messageId++;
230
+ const formattingRequest = {
231
+ jsonrpc: '2.0',
232
+ id: currentId,
233
+ method: 'textDocument/formatting',
234
+ params: {
235
+ textDocument: {
236
+ uri: 'file:///test.ets'
237
+ },
238
+ options: {
239
+ tabSize: 2,
240
+ insertSpaces: true
241
+ }
242
+ }
243
+ };
244
+
245
+ const message = createLSPMessage(formattingRequest);
246
+ serverProcess.stdin.write(message);
247
+
248
+ const response = await waitForResponse(
249
+ responses,
250
+ r => r.id === currentId
251
+ );
252
+
253
+ expect(response).toBeDefined();
254
+ const textEdit = response.result[0];
255
+
256
+ // 验证使用空格缩进(2个空格)
257
+ const lines = textEdit.newText.split('\n');
258
+ const indentedLines = lines.filter(line => line.startsWith(' '));
259
+ expect(indentedLines.length).toBeGreaterThan(0);
260
+
261
+ // 验证使用了空格而不是制表符
262
+ expect(textEdit.newText).not.toContain('\t');
263
+ });
264
+
265
+ it('should format with different tab sizes', async () => {
266
+ const currentId = messageId++;
267
+ const formattingRequest = {
268
+ jsonrpc: '2.0',
269
+ id: currentId,
270
+ method: 'textDocument/formatting',
271
+ params: {
272
+ textDocument: {
273
+ uri: 'file:///test.ets'
274
+ },
275
+ options: {
276
+ tabSize: 4,
277
+ insertSpaces: true
278
+ }
279
+ }
280
+ };
281
+
282
+ const message = createLSPMessage(formattingRequest);
283
+ serverProcess.stdin.write(message);
284
+
285
+ const response = await waitForResponse(
286
+ responses,
287
+ r => r.id === currentId
288
+ );
289
+
290
+ expect(response).toBeDefined();
291
+ expect(response.result).toBeDefined();
292
+
293
+ const textEdit = response.result[0];
294
+ expect(textEdit.newText).toBeDefined();
295
+ expect(textEdit.newText.length).toBeGreaterThan(0);
296
+ });
297
+
298
+ it('should handle range formatting and return proper content', async () => {
299
+ const currentId = messageId++;
300
+ const rangeFormattingRequest = {
301
+ jsonrpc: '2.0',
302
+ id: currentId,
303
+ method: 'textDocument/rangeFormatting',
304
+ params: {
305
+ textDocument: {
306
+ uri: 'file:///test.ets'
307
+ },
308
+ range: {
309
+ start: { line: 0, character: 0 },
310
+ end: { line: 10, character: 0 }
311
+ },
312
+ options: {
313
+ tabSize: 2,
314
+ insertSpaces: true
315
+ }
316
+ }
317
+ };
318
+
319
+ const message = createLSPMessage(rangeFormattingRequest);
320
+ serverProcess.stdin.write(message);
321
+
322
+ const response = await waitForResponse(
323
+ responses,
324
+ r => r.id === currentId
325
+ );
326
+
327
+ expect(response).toBeDefined();
328
+ expect(response.result).toBeDefined();
329
+
330
+ const textEdit = response.result[0];
331
+ expect(textEdit.newText).toBeDefined();
332
+ expect(textEdit.range).toBeDefined();
333
+ });
334
+
335
+ it('should return formatted content that is syntactically valid', async () => {
336
+ const currentId = messageId++;
337
+ const formattingRequest = {
338
+ jsonrpc: '2.0',
339
+ id: currentId,
340
+ method: 'textDocument/formatting',
341
+ params: {
342
+ textDocument: {
343
+ uri: 'file:///test.ets'
344
+ },
345
+ options: {
346
+ tabSize: 2,
347
+ insertSpaces: true
348
+ }
349
+ }
350
+ };
351
+
352
+ const message = createLSPMessage(formattingRequest);
353
+ serverProcess.stdin.write(message);
354
+
355
+ const response = await waitForResponse(
356
+ responses,
357
+ r => r.id === currentId
358
+ );
359
+
360
+ expect(response).toBeDefined();
361
+ const textEdit = response.result[0];
362
+ const formattedCode = textEdit.newText;
363
+
364
+ // 验证格式化后的代码包含必要的ArkTS元素
365
+ expect(formattedCode).toContain('@Entry');
366
+ expect(formattedCode).toContain('@Component');
367
+ expect(formattedCode).toContain('struct');
368
+ expect(formattedCode).toContain('build()');
369
+
370
+ // 验证代码结构完整(有开始和结束的大括号)
371
+ const openBraces = (formattedCode.match(/{/g) || []).length;
372
+ const closeBraces = (formattedCode.match(/}/g) || []).length;
373
+ expect(openBraces).toBe(closeBraces);
374
+ });
375
+
376
+ it('should handle multiple consecutive formatting requests with correct results', async () => {
377
+ const results = [];
378
+
379
+ for (let i = 0; i < 3; i++) {
380
+ const currentId = messageId++;
381
+ const formattingRequest = {
382
+ jsonrpc: '2.0',
383
+ id: currentId,
384
+ method: 'textDocument/formatting',
385
+ params: {
386
+ textDocument: {
387
+ uri: `file:///test${i}.ets`
388
+ },
389
+ options: {
390
+ tabSize: 2,
391
+ insertSpaces: true
392
+ }
393
+ }
394
+ };
395
+
396
+ const message = createLSPMessage(formattingRequest);
397
+ serverProcess.stdin.write(message);
398
+
399
+ const response = await waitForResponse(
400
+ responses,
401
+ r => r.id === currentId
402
+ );
403
+
404
+ expect(response).toBeDefined();
405
+ expect(response.result).toBeDefined();
406
+
407
+ const textEdit = response.result[0];
408
+ expect(textEdit.newText).toBeDefined();
409
+ results.push(textEdit.newText);
410
+ }
411
+
412
+ // 验证所有格式化结果都是有效的
413
+ results.forEach(formattedContent => {
414
+ expect(formattedContent.length).toBeGreaterThan(0);
415
+ expect(formattedContent).toContain('struct');
416
+ });
417
+ });
418
+ });
419
+
420
+ describe('Formatting TextEdit Application', () => {
421
+ it('should correctly apply TextEdit to replace entire document', () => {
422
+ const originalText = 'Line 1\nLine 2\nLine 3';
423
+ const textEdit = {
424
+ range: {
425
+ start: { line: 0, character: 0 },
426
+ end: { line: 2, character: 6 }
427
+ },
428
+ newText: 'New Line 1\nNew Line 2\nNew Line 3'
429
+ };
430
+
431
+ const result = applyTextEdits(originalText, [textEdit]);
432
+ expect(result).toBe('New Line 1\nNew Line 2\nNew Line 3');
433
+ });
434
+
435
+ it('should correctly apply TextEdit to replace partial content', () => {
436
+ const originalText = 'Hello World\nTest Line';
437
+ const textEdit = {
438
+ range: {
439
+ start: { line: 0, character: 6 },
440
+ end: { line: 0, character: 11 }
441
+ },
442
+ newText: 'ArkTS'
443
+ };
444
+
445
+ const result = applyTextEdits(originalText, [textEdit]);
446
+ expect(result).toBe('Hello ArkTS\nTest Line');
447
+ });
448
+
449
+ it('should handle multiple TextEdits', () => {
450
+ const originalText = 'Line 1\nLine 2\nLine 3';
451
+ const textEdits = [
452
+ {
453
+ range: {
454
+ start: { line: 0, character: 0 },
455
+ end: { line: 0, character: 6 }
456
+ },
457
+ newText: 'First'
458
+ },
459
+ {
460
+ range: {
461
+ start: { line: 2, character: 0 },
462
+ end: { line: 2, character: 6 }
463
+ },
464
+ newText: 'Third'
465
+ }
466
+ ];
467
+
468
+ const result = applyTextEdits(originalText, textEdits);
469
+ expect(result).toContain('First');
470
+ expect(result).toContain('Third');
471
+ });
472
+ });
@@ -0,0 +1,409 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { spawn } from 'child_process';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ /**
10
+ * 创建 LSP 消息
11
+ */
12
+ function createLSPMessage(content) {
13
+ const json = JSON.stringify(content);
14
+ const contentLength = Buffer.byteLength(json, 'utf8');
15
+ return `Content-Length: ${contentLength}\r\n\r\n${json}`;
16
+ }
17
+
18
+ /**
19
+ * 解析 LSP 响应
20
+ */
21
+ function parseLSPResponse(data) {
22
+ const text = data.toString();
23
+ const match = text.match(/Content-Length: (\d+)\r\n\r\n(.*)/s);
24
+ if (!match) return null;
25
+
26
+ try {
27
+ return JSON.parse(match[2]);
28
+ } catch (e) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 等待特定响应
35
+ */
36
+ function waitForResponse(responses, predicate, timeout = 2000) {
37
+ return new Promise((resolve, reject) => {
38
+ const startTime = Date.now();
39
+ const checkInterval = setInterval(() => {
40
+ const response = responses.find(predicate);
41
+ if (response) {
42
+ clearInterval(checkInterval);
43
+ resolve(response);
44
+ } else if (Date.now() - startTime > timeout) {
45
+ clearInterval(checkInterval);
46
+ reject(new Error('Timeout waiting for response'));
47
+ }
48
+ }, 100);
49
+ });
50
+ }
51
+
52
+ describe('Code Formatting Tests', () => {
53
+ let serverProcess;
54
+ let responses = [];
55
+ let messageId = 1;
56
+
57
+ beforeAll(() => {
58
+ // 启动 LSP 服务器
59
+ const serverPath = join(__dirname, '../../index.js');
60
+
61
+ // 设置环境变量,指向模拟的 ETS 语言服务器
62
+ const env = {
63
+ ...process.env,
64
+ ETS_LANG_SERVER: join(__dirname, '../mocks/mock-ets-server.js')
65
+ };
66
+
67
+ serverProcess = spawn('node', [serverPath], {
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ env
70
+ });
71
+
72
+ // 收集响应
73
+ serverProcess.stdout.on('data', (data) => {
74
+ const response = parseLSPResponse(data);
75
+ if (response) {
76
+ responses.push(response);
77
+ }
78
+ });
79
+
80
+ serverProcess.stderr.on('data', (data) => {
81
+ console.error(`LSP Server Error: ${data}`);
82
+ });
83
+ });
84
+
85
+ afterAll(() => {
86
+ if (serverProcess) {
87
+ serverProcess.kill();
88
+ }
89
+ });
90
+
91
+ it('should forward textDocument/formatting to ets/formatDocument', async () => {
92
+ const formattingRequest = {
93
+ jsonrpc: '2.0',
94
+ id: messageId++,
95
+ method: 'textDocument/formatting',
96
+ params: {
97
+ textDocument: {
98
+ uri: 'file:///test.ets'
99
+ },
100
+ options: {
101
+ tabSize: 2,
102
+ insertSpaces: true
103
+ }
104
+ }
105
+ };
106
+
107
+ const message = createLSPMessage(formattingRequest);
108
+ serverProcess.stdin.write(message);
109
+
110
+ // 由于我们转发到 ets/formatDocument,验证请求被正确转发
111
+ // 在实际实现中,这会返回 TextEdit[] 数组
112
+ await new Promise(resolve => setTimeout(resolve, 500));
113
+
114
+ // 验证服务器仍在运行(没有因为错误崩溃)
115
+ expect(serverProcess.killed).toBe(false);
116
+ });
117
+
118
+ it('should forward textDocument/rangeFormatting to ets/formatDocument', async () => {
119
+ const rangeFormattingRequest = {
120
+ jsonrpc: '2.0',
121
+ id: messageId++,
122
+ method: 'textDocument/rangeFormatting',
123
+ params: {
124
+ textDocument: {
125
+ uri: 'file:///test.ets'
126
+ },
127
+ range: {
128
+ start: { line: 0, character: 0 },
129
+ end: { line: 10, character: 0 }
130
+ },
131
+ options: {
132
+ tabSize: 2,
133
+ insertSpaces: true
134
+ }
135
+ }
136
+ };
137
+
138
+ const message = createLSPMessage(rangeFormattingRequest);
139
+ serverProcess.stdin.write(message);
140
+
141
+ // 验证请求被转发
142
+ await new Promise(resolve => setTimeout(resolve, 500));
143
+
144
+ // 验证服务器仍在运行
145
+ expect(serverProcess.killed).toBe(false);
146
+ });
147
+
148
+ it('should preserve formatting options when forwarding', async () => {
149
+ const formattingRequest = {
150
+ jsonrpc: '2.0',
151
+ id: messageId++,
152
+ method: 'textDocument/formatting',
153
+ params: {
154
+ textDocument: {
155
+ uri: 'file:///test.ets'
156
+ },
157
+ options: {
158
+ tabSize: 4,
159
+ insertSpaces: false,
160
+ trimTrailingWhitespace: true,
161
+ insertFinalNewline: true,
162
+ trimFinalNewlines: true
163
+ }
164
+ }
165
+ };
166
+
167
+ const message = createLSPMessage(formattingRequest);
168
+ serverProcess.stdin.write(message);
169
+
170
+ await new Promise(resolve => setTimeout(resolve, 500));
171
+
172
+ // 验证服务器正常处理带有完整选项的请求
173
+ expect(serverProcess.killed).toBe(false);
174
+ });
175
+
176
+ it('should handle formatting request with minimal options', async () => {
177
+ const formattingRequest = {
178
+ jsonrpc: '2.0',
179
+ id: messageId++,
180
+ method: 'textDocument/formatting',
181
+ params: {
182
+ textDocument: {
183
+ uri: 'file:///simple.ets'
184
+ },
185
+ options: {
186
+ tabSize: 2,
187
+ insertSpaces: true
188
+ }
189
+ }
190
+ };
191
+
192
+ const message = createLSPMessage(formattingRequest);
193
+ serverProcess.stdin.write(message);
194
+
195
+ await new Promise(resolve => setTimeout(resolve, 500));
196
+
197
+ expect(serverProcess.killed).toBe(false);
198
+ });
199
+
200
+ it('should handle multiple formatting requests sequentially', async () => {
201
+ const files = ['file1.ets', 'file2.ets', 'file3.ets'];
202
+
203
+ for (const file of files) {
204
+ const formattingRequest = {
205
+ jsonrpc: '2.0',
206
+ id: messageId++,
207
+ method: 'textDocument/formatting',
208
+ params: {
209
+ textDocument: {
210
+ uri: `file:///${file}`
211
+ },
212
+ options: {
213
+ tabSize: 2,
214
+ insertSpaces: true
215
+ }
216
+ }
217
+ };
218
+
219
+ const message = createLSPMessage(formattingRequest);
220
+ serverProcess.stdin.write(message);
221
+
222
+ // 给每个请求一些处理时间
223
+ await new Promise(resolve => setTimeout(resolve, 200));
224
+ }
225
+
226
+ // 验证服务器处理了所有请求而没有崩溃
227
+ expect(serverProcess.killed).toBe(false);
228
+ });
229
+
230
+ it('should handle formatting request with different tab sizes', async () => {
231
+ const tabSizes = [2, 4, 8];
232
+
233
+ for (const tabSize of tabSizes) {
234
+ const formattingRequest = {
235
+ jsonrpc: '2.0',
236
+ id: messageId++,
237
+ method: 'textDocument/formatting',
238
+ params: {
239
+ textDocument: {
240
+ uri: 'file:///test.ets'
241
+ },
242
+ options: {
243
+ tabSize: tabSize,
244
+ insertSpaces: true
245
+ }
246
+ }
247
+ };
248
+
249
+ const message = createLSPMessage(formattingRequest);
250
+ serverProcess.stdin.write(message);
251
+
252
+ await new Promise(resolve => setTimeout(resolve, 200));
253
+ }
254
+
255
+ expect(serverProcess.killed).toBe(false);
256
+ });
257
+
258
+ it('should handle formatting with tabs instead of spaces', async () => {
259
+ const formattingRequest = {
260
+ jsonrpc: '2.0',
261
+ id: messageId++,
262
+ method: 'textDocument/formatting',
263
+ params: {
264
+ textDocument: {
265
+ uri: 'file:///test.ets'
266
+ },
267
+ options: {
268
+ tabSize: 2,
269
+ insertSpaces: false // Use tabs
270
+ }
271
+ }
272
+ };
273
+
274
+ const message = createLSPMessage(formattingRequest);
275
+ serverProcess.stdin.write(message);
276
+
277
+ await new Promise(resolve => setTimeout(resolve, 500));
278
+
279
+ expect(serverProcess.killed).toBe(false);
280
+ });
281
+ });
282
+
283
+ describe('Formatting Message Structure', () => {
284
+ it('should create valid formatting request message', () => {
285
+ const request = {
286
+ jsonrpc: '2.0',
287
+ id: 1,
288
+ method: 'textDocument/formatting',
289
+ params: {
290
+ textDocument: { uri: 'file:///test.ets' },
291
+ options: { tabSize: 2, insertSpaces: true }
292
+ }
293
+ };
294
+
295
+ const message = createLSPMessage(request);
296
+
297
+ expect(message).toContain('Content-Length:');
298
+ expect(message).toContain('textDocument/formatting');
299
+ expect(message).toContain('file:///test.ets');
300
+ });
301
+
302
+ it('should create valid range formatting request message', () => {
303
+ const request = {
304
+ jsonrpc: '2.0',
305
+ id: 2,
306
+ method: 'textDocument/rangeFormatting',
307
+ params: {
308
+ textDocument: { uri: 'file:///test.ets' },
309
+ range: {
310
+ start: { line: 0, character: 0 },
311
+ end: { line: 10, character: 0 }
312
+ },
313
+ options: { tabSize: 2, insertSpaces: true }
314
+ }
315
+ };
316
+
317
+ const message = createLSPMessage(request);
318
+
319
+ expect(message).toContain('Content-Length:');
320
+ expect(message).toContain('textDocument/rangeFormatting');
321
+ expect(message).toContain('"start"');
322
+ expect(message).toContain('"end"');
323
+ });
324
+
325
+ it('should validate formatting options structure', () => {
326
+ const options = {
327
+ tabSize: 2,
328
+ insertSpaces: true,
329
+ trimTrailingWhitespace: true,
330
+ insertFinalNewline: true,
331
+ trimFinalNewlines: true
332
+ };
333
+
334
+ expect(options.tabSize).toBeGreaterThan(0);
335
+ expect(typeof options.insertSpaces).toBe('boolean');
336
+ expect(typeof options.trimTrailingWhitespace).toBe('boolean');
337
+ expect(typeof options.insertFinalNewline).toBe('boolean');
338
+ expect(typeof options.trimFinalNewlines).toBe('boolean');
339
+ });
340
+ });
341
+
342
+ describe('Formatting Edge Cases', () => {
343
+ it('should handle formatting request for empty file', () => {
344
+ const request = {
345
+ jsonrpc: '2.0',
346
+ id: 1,
347
+ method: 'textDocument/formatting',
348
+ params: {
349
+ textDocument: { uri: 'file:///empty.ets' },
350
+ options: { tabSize: 2, insertSpaces: true }
351
+ }
352
+ };
353
+
354
+ const message = createLSPMessage(request);
355
+ expect(message).toBeDefined();
356
+ });
357
+
358
+ it('should handle formatting request for large files', () => {
359
+ const request = {
360
+ jsonrpc: '2.0',
361
+ id: 1,
362
+ method: 'textDocument/formatting',
363
+ params: {
364
+ textDocument: { uri: 'file:///large-file.ets' },
365
+ options: { tabSize: 2, insertSpaces: true }
366
+ }
367
+ };
368
+
369
+ const message = createLSPMessage(request);
370
+ expect(message).toBeDefined();
371
+ });
372
+
373
+ it('should handle range formatting with zero-width range', () => {
374
+ const request = {
375
+ jsonrpc: '2.0',
376
+ id: 1,
377
+ method: 'textDocument/rangeFormatting',
378
+ params: {
379
+ textDocument: { uri: 'file:///test.ets' },
380
+ range: {
381
+ start: { line: 5, character: 10 },
382
+ end: { line: 5, character: 10 }
383
+ },
384
+ options: { tabSize: 2, insertSpaces: true }
385
+ }
386
+ };
387
+
388
+ const message = createLSPMessage(request);
389
+ expect(message).toBeDefined();
390
+ });
391
+
392
+ it('should handle formatting options with edge values', () => {
393
+ const request = {
394
+ jsonrpc: '2.0',
395
+ id: 1,
396
+ method: 'textDocument/formatting',
397
+ params: {
398
+ textDocument: { uri: 'file:///test.ets' },
399
+ options: {
400
+ tabSize: 1, // Minimum tab size
401
+ insertSpaces: true
402
+ }
403
+ }
404
+ };
405
+
406
+ const message = createLSPMessage(request);
407
+ expect(message).toBeDefined();
408
+ });
409
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { spawn } from 'child_process';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ /**
10
+ * 创建 LSP 消息
11
+ */
12
+ function createLSPMessage(content) {
13
+ const json = JSON.stringify(content);
14
+ const contentLength = Buffer.byteLength(json, 'utf8');
15
+ return `Content-Length: ${contentLength}\r\n\r\n${json}`;
16
+ }
17
+
18
+ /**
19
+ * 解析 LSP 响应
20
+ */
21
+ function parseLSPResponse(data) {
22
+ const text = data.toString();
23
+ const match = text.match(/Content-Length: (\d+)\r\n\r\n(.*)/s);
24
+ if (!match) return null;
25
+
26
+ try {
27
+ return JSON.parse(match[2]);
28
+ } catch (e) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ describe('LSP Server Integration Tests', () => {
34
+ let serverProcess;
35
+ let responses = [];
36
+
37
+ beforeAll(() => {
38
+ // 启动 LSP 服务器
39
+ const serverPath = join(__dirname, '../../index.js');
40
+ serverProcess = spawn('node', [serverPath], {
41
+ stdio: ['pipe', 'pipe', 'pipe']
42
+ });
43
+
44
+ // 收集响应
45
+ serverProcess.stdout.on('data', (data) => {
46
+ const response = parseLSPResponse(data);
47
+ if (response) {
48
+ responses.push(response);
49
+ }
50
+ });
51
+
52
+ serverProcess.stderr.on('data', (data) => {
53
+ console.error(`LSP Server Error: ${data}`);
54
+ });
55
+ });
56
+
57
+ afterAll(() => {
58
+ if (serverProcess) {
59
+ serverProcess.kill();
60
+ }
61
+ });
62
+
63
+ it('should respond to initialize request', (done) => {
64
+ const initRequest = {
65
+ jsonrpc: '2.0',
66
+ id: 1,
67
+ method: 'initialize',
68
+ params: {
69
+ processId: process.pid,
70
+ rootUri: null,
71
+ capabilities: {}
72
+ }
73
+ };
74
+
75
+ const message = createLSPMessage(initRequest);
76
+ serverProcess.stdin.write(message);
77
+
78
+ // 等待响应
79
+ setTimeout(() => {
80
+ const initResponse = responses.find(r => r.id === 1);
81
+ expect(initResponse).toBeDefined();
82
+ expect(initResponse.result).toBeDefined();
83
+ expect(initResponse.result.capabilities).toBeDefined();
84
+ done();
85
+ }, 1000);
86
+ });
87
+
88
+ it('should accept initialized notification', (done) => {
89
+ const initializedNotif = {
90
+ jsonrpc: '2.0',
91
+ method: 'initialized',
92
+ params: {}
93
+ };
94
+
95
+ const message = createLSPMessage(initializedNotif);
96
+ serverProcess.stdin.write(message);
97
+
98
+ // 通知不需要响应,只需确保不崩溃
99
+ setTimeout(() => {
100
+ expect(serverProcess.killed).toBe(false);
101
+ done();
102
+ }, 500);
103
+ });
104
+
105
+ it('should handle shutdown request', (done) => {
106
+ const shutdownRequest = {
107
+ jsonrpc: '2.0',
108
+ id: 99,
109
+ method: 'shutdown',
110
+ params: null
111
+ };
112
+
113
+ const message = createLSPMessage(shutdownRequest);
114
+ serverProcess.stdin.write(message);
115
+
116
+ setTimeout(() => {
117
+ const shutdownResponse = responses.find(r => r.id === 99);
118
+ // 某些 LSP 服务器可能返回 null result
119
+ expect(shutdownResponse).toBeDefined();
120
+ done();
121
+ }, 1000);
122
+ });
123
+ });
124
+
125
+ describe('LSP Message Protocol', () => {
126
+ it('should format messages correctly', () => {
127
+ const content = { jsonrpc: '2.0', method: 'test' };
128
+ const message = createLSPMessage(content);
129
+
130
+ expect(message).toContain('Content-Length:');
131
+ expect(message).toContain('\r\n\r\n');
132
+ expect(message).toContain(JSON.stringify(content));
133
+ });
134
+
135
+ it('should parse responses correctly', () => {
136
+ const mockResponse = {
137
+ jsonrpc: '2.0',
138
+ id: 1,
139
+ result: { success: true }
140
+ };
141
+ const data = createLSPMessage(mockResponse);
142
+ const parsed = parseLSPResponse(Buffer.from(data));
143
+
144
+ expect(parsed).toEqual(mockResponse);
145
+ });
146
+ });
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Mock ETS Language Server for testing
5
+ * This simulates the behavior of the actual ETS language server
6
+ * for formatting requests.
7
+ */
8
+
9
+ import { readFileSync } from 'fs';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+
16
+ // Load test fixtures
17
+ const fixturesDir = join(__dirname, '../fixtures');
18
+
19
+ process.on('message', (message) => {
20
+ // Handle ets/formatDocument requests
21
+ if (message.method === 'ets/formatDocument') {
22
+ const uri = message.params.textDocument.uri;
23
+
24
+ // Simulate realistic formatting by returning TextEdit that formats the content
25
+ // For testing, we'll simulate formatting unformatted.ets to formatted.ets
26
+ let formattedContent;
27
+
28
+ try {
29
+ // In a real scenario, the server would format based on the actual file content
30
+ // For testing, we return a predefined formatted version
31
+ if (uri.includes('unformatted')) {
32
+ formattedContent = readFileSync(join(fixturesDir, 'formatted.ets'), 'utf8');
33
+ } else {
34
+ // For other files, simulate basic formatting
35
+ formattedContent = `// Formatted by mock ETS server
36
+ @Entry
37
+ @Component
38
+ struct Test {
39
+ @State value: string = 'test'
40
+
41
+ build() {
42
+ Column() {
43
+ Text(this.value)
44
+ }
45
+ }
46
+ }
47
+ `;
48
+ }
49
+
50
+ // Count lines in the formatted content to calculate proper end position
51
+ const lines = formattedContent.split('\n');
52
+ const lastLineIndex = lines.length - 1;
53
+ const lastLineLength = lines[lastLineIndex].length;
54
+
55
+ const response = {
56
+ jsonrpc: message.jsonrpc,
57
+ id: message.id,
58
+ result: [
59
+ {
60
+ range: {
61
+ start: { line: 0, character: 0 },
62
+ end: { line: lastLineIndex, character: lastLineLength }
63
+ },
64
+ newText: formattedContent
65
+ }
66
+ ]
67
+ };
68
+
69
+ process.send(response);
70
+ } catch (error) {
71
+ // Return error response if something goes wrong
72
+ const errorResponse = {
73
+ jsonrpc: message.jsonrpc,
74
+ id: message.id,
75
+ error: {
76
+ code: -32603,
77
+ message: `Formatting error: ${error.message}`
78
+ }
79
+ };
80
+ process.send(errorResponse);
81
+ }
82
+ }
83
+
84
+ // Handle initialize request
85
+ else if (message.method === 'initialize') {
86
+ const response = {
87
+ jsonrpc: message.jsonrpc,
88
+ id: message.id,
89
+ result: {
90
+ capabilities: {
91
+ textDocumentSync: 1,
92
+ documentFormattingProvider: true,
93
+ documentRangeFormattingProvider: true
94
+ }
95
+ }
96
+ };
97
+
98
+ process.send(response);
99
+ }
100
+
101
+ // Handle shutdown request
102
+ else if (message.method === 'shutdown') {
103
+ const response = {
104
+ jsonrpc: message.jsonrpc,
105
+ id: message.id,
106
+ result: null
107
+ };
108
+
109
+ process.send(response);
110
+ }
111
+
112
+ // Handle exit notification
113
+ else if (message.method === 'exit') {
114
+ process.exit(0);
115
+ }
116
+ });
117
+
118
+ // Keep the process alive
119
+ process.stdin.resume();