zed-ets-language-server 2.0.1 → 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 +37 -0
- package/package.json +7 -3
- package/tests/fixtures/formatted.ets +21 -0
- package/tests/fixtures/unformatted.ets +17 -0
- package/tests/integration/formatting-content.test.js +472 -0
- package/tests/integration/formatting.test.js +409 -0
- package/tests/integration/lsp-server.test.js +146 -0
- package/tests/mocks/mock-ets-server.js +119 -0
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.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,12 +8,16 @@
|
|
|
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",
|
|
15
19
|
"dependencies": {
|
|
16
|
-
"@arkts/language-server": "^1.2.
|
|
20
|
+
"@arkts/language-server": "^1.2.8"
|
|
17
21
|
},
|
|
18
22
|
"devDependencies": {
|
|
19
23
|
"vitest": "^4.0.9"
|
|
@@ -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();
|