deel-local-cli 1.2.0 → 1.4.1

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 (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
@@ -0,0 +1,459 @@
1
+ /**
2
+ * 언어 서버 하나를 띄워 놓고 물어보는 자리.
3
+ *
4
+ * ── 왜 붙이나 ───────────────────────────────────────────────────────────
5
+ *
6
+ * Grep 은 **글자**를 찾고 언어 서버는 **뜻**을 안다. 이 차이가 어디서 나는지는
7
+ * `handle` 같은 이름을 한 번 고쳐 보면 안다. Grep 은 주석에 든 handle, 남의
8
+ * 라이브러리의 handle, 문자열 안의 handle 을 다 같이 준다. 그중 진짜 그 함수를
9
+ * 부르는 자리가 몇 개인지는 사람이 하나씩 열어 봐야 안다 — 모델은 그 값을 못
10
+ * 치르니까 대충 몇 개만 보고 고치고, 놓친 자리는 돌려 본 뒤에야 드러난다.
11
+ *
12
+ * 그렇다고 Grep 을 밀어내지 않는다. 언어 서버가 없는 자리가 더 많고(사내망이
13
+ * 대개 그렇다), 있어도 못 읽는 파일이 있다. 그래서 Def·Refs 는 **더해 주는**
14
+ * 것이지 갈아 끼우는 것이 아니다. 서버가 없으면 그 두 도구는 목록에 아예 안
15
+ * 나오고, 지금까지 하던 대로 Grep·Outline 으로 간다.
16
+ *
17
+ * ── 여기서 제일 조심한 것: 안 멈추게 하는 것 ────────────────────────────
18
+ *
19
+ * 언어 서버는 남의 프로그램이다. 답을 안 줄 수도 있고, 켜다 죽을 수도 있고,
20
+ * 죽은 척하며 살아 있을 수도 있다. 그런데 이쪽은 사람이 쳐 놓고 기다리는
21
+ * 대화창이다. 여기서 한 번 멎으면 사용자가 할 수 있는 일은 Ctrl+C 뿐이다.
22
+ *
23
+ * 그래서 **모든 물음에 시한이 있다.** 시한이 지나면 답 대신 '못 받았다' 를
24
+ * 내주고 그대로 넘어간다. 켜다 실패하면 그 언어는 이 세션 동안 다시 안 켠다 —
25
+ * 안 그러면 부를 때마다 몇 초씩 까먹는다. 프로세스는 셋 다(정상 종료·시한 초과
26
+ * ·부모 종료) 반드시 정리한다. 유령이 남으면 사용자 컴퓨터에 남는다.
27
+ */
28
+ import { spawn } from 'node:child_process';
29
+ import { pathToFileURL, fileURLToPath } from 'node:url';
30
+ import { readFileSync } from 'node:fs';
31
+ import { 갈래, 고르기, 언어아이디 } from './servers.js';
32
+ import { 틀, 받개 } from './rpc.js';
33
+
34
+ // 켜는 데 이만큼 넘게 걸리면 포기한다. rust-analyzer 처럼 무거운 것도
35
+ // **악수(initialize)** 자체는 빠르다 — 오래 걸리는 것은 그 뒤의 색인이다.
36
+ const 켜기시한 = 20_000;
37
+ // 물음 하나의 시한. 색인 중이면 늦게 오는데, 그렇다고 대화창을 잡아 둘 수 없다.
38
+ const 물음시한 = 15_000;
39
+ // 아무도 안 쓰면 이만큼 뒤에 끈다. 언어 서버는 메모리를 꽤 먹는다.
40
+ const 놀림시한 = 5 * 60_000;
41
+
42
+ let _다음번호 = 0;
43
+
44
+ /**
45
+ * 주소를 견줄 수 있는 하나의 꼴로 만든다.
46
+ *
47
+ * 같은 파일을 서버와 우리가 다르게 적는다. 우리는 `file:///C:/…` 이고
48
+ * pyright 은 `file:///c%3A/…` 다 — 드라이브 글자를 소문자로 쓰고 콜론을
49
+ * 퍼센트로 감싼다. 글자로 견주면 **영영 안 맞는다.**
50
+ *
51
+ * 이게 조용히 아픈 자리였다. 진단은 제대로 오는데 우리 표에서 못 찾으니
52
+ * '안 왔다' 가 되고, 고친 뒤 진단이 아무 말도 안 하게 된다. 아무 말도 안 하는
53
+ * 것이 '성하다' 는 뜻이라 — 틀린 것을 성하다고 말하는 셈이 된다.
54
+ *
55
+ * 그래서 주소가 아니라 **경로**로 견준다. 윈도우는 대소문자를 안 가리므로
56
+ * 거기서만 소문자로 눕힌다.
57
+ */
58
+ function 열쇠주소(uri) {
59
+ try {
60
+ const p = fileURLToPath(uri);
61
+ return process.platform === 'win32' ? p.toLowerCase() : p;
62
+ } catch {
63
+ return String(uri ?? '');
64
+ }
65
+ }
66
+
67
+ export class 언어서버 {
68
+ /**
69
+ * @param 뿌리 작업 폴더. 서버의 workspace 가 된다 — 이 밖은 안 본다.
70
+ * @param 고른것 servers.js 의 고르기() 가 준 것
71
+ */
72
+ constructor(뿌리, 고른것) {
73
+ this.뿌리 = 뿌리;
74
+ this.서버 = 고른것;
75
+ this.아이 = null;
76
+ this.받개 = null;
77
+ this.기다림 = new Map(); // id → {풀기, 시계}
78
+ this.진단 = new Map(); // uri → [진단…]
79
+ this.연것 = new Map(); // uri → 판 번호
80
+ this.준비 = null; // 켜는 중이면 그 약속
81
+ this.죽음 = null; // 못 켠 이유. 차 있으면 다시 안 켠다
82
+ this.능력 = {};
83
+ this.놀림시계 = null;
84
+ this.끄는중 = false;
85
+ this.켜진때 = 0; // 색인이 아직 안 끝났을 만한 때인지 재는 데 쓴다
86
+ this.기다리는진단 = 0; // 진단을 기다리는 중인 파일 수 (#잡기·#놓기 참고)
87
+ }
88
+
89
+ 살았나() { return !!this.아이 && this.아이.exitCode === null && !this.아이.killed; }
90
+
91
+ // ── 켜기 ──────────────────────────────────────────────────────────────
92
+
93
+ async 켜기() {
94
+ if (this.죽음) return false; // 한 번 실패한 것은 다시 안 켠다
95
+ if (this.살았나() && this.능력.ready) return true;
96
+ if (this.준비) return await this.준비;
97
+ this.준비 = this.#켜기실제().finally(() => { this.준비 = null; });
98
+ return await this.준비;
99
+ }
100
+
101
+ async #켜기실제() {
102
+ try {
103
+ this.받개 = 받개();
104
+
105
+ /*
106
+ * 윈도우에서 npm 이 전역으로 깐 것은 `.cmd` 라 그냥은 안 뜬다.
107
+ *
108
+ * shell: true 로 넘기면 되긴 하는데, node 가 그 조합에 경고를 낸다
109
+ * (DEP0190 — 인자가 안 감싸진 채로 이어 붙는다). 경고가 화면에 섞이는
110
+ * 것도 문제지만, 그 지적이 맞다 — 경로에 빈칸이 있으면 그대로 깨진다.
111
+ * 그래서 셸이 정말 필요한 것(.cmd·.bat)만 골라서 cmd.exe 로 직접 부르고,
112
+ * 나머지는 셸 없이 그대로 띄운다.
113
+ */
114
+ const 실행 = this.서버.경로 ?? this.서버.cmd;
115
+ const 셸필요 = process.platform === 'win32' && /\.(cmd|bat)$/i.test(실행);
116
+ /*
117
+ * `cmd /s /c` 는 뒤에 오는 것이 따옴표로 시작해서 따옴표로 끝나면
118
+ * **그 바깥 한 쌍을 떼어 낸다.** 그래서 한 쌍만 두르면
119
+ * "C:\...\x.CMD" "--stdio" → C:\...\x.CMD" "--stdio
120
+ * 가 되어 통째로 깨진다. 실제로 깨졌다 — cmd 가 "경로가 아닙니다" 하고
121
+ * 코드 1로 죽는데, 겉에서 보이는 것은 "언어 서버가 없습니다" 뿐이다.
122
+ * 있다고 해 놓고 안 되는, 제일 알아채기 어려운 꼴이다.
123
+ *
124
+ * 그래서 한 겹 더 두른다. 바깥 한 쌍을 떼고 나면 원하던 모양이 남는다.
125
+ */
126
+ const 몰아쓰기 = `""${실행}" ${this.서버.args.map((a) => `"${a}"`).join(' ')}"`;
127
+ this.아이 = 셸필요
128
+ ? spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 몰아쓰기], {
129
+ cwd: this.뿌리,
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ windowsVerbatimArguments: true,
132
+ windowsHide: true,
133
+ })
134
+ : spawn(실행, this.서버.args, {
135
+ cwd: this.뿌리,
136
+ stdio: ['pipe', 'pipe', 'pipe'],
137
+ windowsHide: true,
138
+ });
139
+
140
+ /*
141
+ * 이 아이가 **프로그램을 붙잡고 있으면 안 된다.**
142
+ *
143
+ * 서버는 고칠 때 뒤에서 저절로 데워진다(diag.js 의 데우기). 그래서 사용자가
144
+ * 아무것도 안 시켰는데도 떠 있을 수 있는데, node 는 살아 있는 자식과 그
145
+ * 파이프가 있으면 할 일이 없어도 안 끝난다. 그러면 `deel run 한마디` 가
146
+ * 답을 다 내놓고도 프롬프트로 안 돌아온다 — 멎은 것처럼 보인다.
147
+ *
148
+ * unref 는 '이것 때문에 기다리지는 마라' 는 뜻이다. 대화가 도는 동안에는
149
+ * 대화 쪽이 잡고 있으니 아무 차이가 없고, 할 일이 없어졌을 때만 티가 난다.
150
+ * 끄는 것은 따로 한다(모두끄기) — 이건 못 끄고 나갈 때의 그물이다.
151
+ */
152
+ this.#놓기();
153
+
154
+ this.아이.on('error', (e) => this.#무너짐(`못 띄웠습니다: ${e.message}`));
155
+ this.아이.on('exit', (code) => {
156
+ // 우리가 끈 것이 아니면 무너진 것이다. 기다리던 물음을 다 풀어 준다 —
157
+ // 안 풀면 그 자리에서 시한까지 통째로 멎는다.
158
+ if (!this.끄는중) this.#무너짐(`서버가 스스로 끝났습니다 (코드 ${code})`);
159
+ });
160
+ this.아이.stdout.on('data', (d) => {
161
+ for (const 통 of this.받개.넣기(d)) this.#받음(통);
162
+ });
163
+ // stderr 는 읽되 버린다. 안 읽으면 파이프가 차서 서버가 멎는다 —
164
+ // 언어 서버는 진행 상황을 stderr 로 꽤 많이 쏟는다.
165
+ this.아이.stderr.on('data', () => {});
166
+
167
+ const 답 = await this.#물음('initialize', {
168
+ processId: process.pid,
169
+ rootUri: pathToFileURL(this.뿌리).href,
170
+ workspaceFolders: [{ uri: pathToFileURL(this.뿌리).href, name: 'root' }],
171
+ capabilities: {
172
+ workspace: { symbol: { dynamicRegistration: false }, workspaceFolders: true, configuration: true },
173
+ textDocument: {
174
+ synchronization: { dynamicRegistration: false, didSave: false },
175
+ definition: { dynamicRegistration: false, linkSupport: true },
176
+ references: { dynamicRegistration: false },
177
+ publishDiagnostics: { relatedInformation: false },
178
+ },
179
+ },
180
+ }, 켜기시한);
181
+
182
+ /*
183
+ * 악수가 실패했으면 여기서 끝낸다.
184
+ *
185
+ * 이 줄이 없으면 못 띄운 서버가 **켜진 것으로 통과한다.** #물음 은 던지지
186
+ * 않고 `{오류}` 를 주도록 만들어 놨기 때문이다(그게 맞다 — 도구 한가운데서
187
+ * 던지면 안 된다). 그래서 여기서는 받은 것을 반드시 들여다봐야 한다.
188
+ * 안 보면 그 뒤 물음이 전부 조용히 빈손으로 돌아온다.
189
+ */
190
+ if (답?.오류) throw new Error(답.오류);
191
+ this.능력 = { ...(답?.값?.capabilities ?? {}), ready: true };
192
+ this.켜진때 = Date.now();
193
+ this.알림('initialized', {});
194
+ this.#놀림다시();
195
+ return true;
196
+ } catch (e) {
197
+ this.#무너짐(e?.message ?? String(e));
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /*
203
+ * '기다리는 것이 있을 때만' 프로그램을 붙잡는다.
204
+ *
205
+ * 서버는 고칠 때 뒤에서 저절로 데워진다(diag.js 의 데우기). 사용자가 아무것도
206
+ * 안 시켰는데 떠 있을 수 있다는 뜻이다. 그런데 node 는 살아 있는 자식과 그
207
+ * 파이프가 있으면 할 일이 없어도 안 끝난다 — `deel run 한마디` 가 답을 다
208
+ * 내놓고도 프롬프트로 안 돌아온다. 멎은 것처럼 보인다.
209
+ *
210
+ * 그렇다고 늘 놔 버리면 반대로 **답을 기다리는 중에 프로그램이 끝난다.**
211
+ * 그래서 물어보는 동안만 잡고, 다 받으면 놓는다. 이 두 줄이 그 여닫이다.
212
+ */
213
+ #잡기() {
214
+ this.아이?.ref?.();
215
+ for (const 줄 of [this.아이?.stdin, this.아이?.stdout, this.아이?.stderr]) 줄?.ref?.();
216
+ }
217
+
218
+ #놓기() {
219
+ if (this.기다림.size || this.기다리는진단) return;
220
+ this.아이?.unref?.();
221
+ for (const 줄 of [this.아이?.stdin, this.아이?.stdout, this.아이?.stderr]) 줄?.unref?.();
222
+ }
223
+
224
+ #무너짐(왜) {
225
+ this.죽음 ??= 왜;
226
+ this.능력.ready = false;
227
+ for (const [, 것] of this.기다림) {
228
+ clearTimeout(것.시계);
229
+ 것.풀기({ 오류: 왜 });
230
+ }
231
+ this.기다림.clear();
232
+ this.기다리는진단 = 0;
233
+ this.#놓기();
234
+ try { this.아이?.kill(); } catch { /* 이미 갔다 */ }
235
+ }
236
+
237
+ // ── 주고받기 ──────────────────────────────────────────────────────────
238
+
239
+ #받음(통) {
240
+ if (통.id !== undefined && this.기다림.has(통.id)) {
241
+ const 것 = this.기다림.get(통.id);
242
+ this.기다림.delete(통.id);
243
+ clearTimeout(것.시계);
244
+ this.#놓기();
245
+ 것.풀기(통.error ? { 오류: 통.error.message ?? String(통.error.code) } : { 값: 통.result });
246
+ return;
247
+ }
248
+ // 서버가 우리에게 묻는 것. 안 답하면 서버가 거기서 멈춰 버리는 것이 있어서
249
+ // (configuration 이 그렇다) 빈 답이라도 반드시 돌려준다.
250
+ if (통.id !== undefined && 통.method) {
251
+ const 값 = 통.method === 'workspace/configuration'
252
+ ? (통.params?.items ?? []).map(() => ({}))
253
+ : null;
254
+ this.#보내기({ jsonrpc: '2.0', id: 통.id, result: 값 });
255
+ return;
256
+ }
257
+ if (통.method === 'textDocument/publishDiagnostics') {
258
+ const p = 통.params ?? {};
259
+ if (p.uri) this.진단.set(열쇠주소(p.uri), Array.isArray(p.diagnostics) ? p.diagnostics : []);
260
+ }
261
+ }
262
+
263
+ #보내기(obj) {
264
+ if (!this.아이?.stdin?.writable) return false;
265
+ try { this.아이.stdin.write(틀(obj)); return true; } catch { return false; }
266
+ }
267
+
268
+ 알림(method, params) {
269
+ this.#놀림다시();
270
+ return this.#보내기({ jsonrpc: '2.0', method, params });
271
+ }
272
+
273
+ /**
274
+ * 물어보고 기다린다. **절대 안 던진다** — 시한이 지나면 `{오류}` 를 준다.
275
+ *
276
+ * 던지지 않는 것이 중요하다. 부르는 쪽은 도구 실행 한가운데이고, 거기서
277
+ * 예외가 나면 "언어 서버가 늦었다" 가 "도구가 터졌다" 로 보고된다.
278
+ */
279
+ #물음(method, params, 시한 = 물음시한) {
280
+ const id = ++_다음번호;
281
+ return new Promise((풀기) => {
282
+ const 시계 = setTimeout(() => {
283
+ this.기다림.delete(id);
284
+ this.#놓기();
285
+ 풀기({ 오류: `${method} 가 ${Math.round(시한 / 1000)}초 안에 안 왔습니다` });
286
+ }, 시한);
287
+ 시계.unref?.();
288
+ this.기다림.set(id, { 풀기, 시계 });
289
+ this.#잡기();
290
+ if (!this.#보내기({ jsonrpc: '2.0', id, method, params })) {
291
+ clearTimeout(시계);
292
+ this.기다림.delete(id);
293
+ this.#놓기();
294
+ 풀기({ 오류: '서버에 못 보냈습니다' });
295
+ }
296
+ });
297
+ }
298
+
299
+ async 물어보기(method, params, 시한) {
300
+ if (!await this.켜기()) return { 오류: this.죽음 ?? '안 켜졌습니다' };
301
+ this.#놀림다시();
302
+ return await this.#물음(method, params, 시한);
303
+ }
304
+
305
+ // ── 파일 알려 주기 ────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * 이 파일을 서버에게 보여 준다. 이미 연 것이면 바뀐 내용으로 갈아 준다.
309
+ *
310
+ * 디스크에 있는 것을 서버가 알아서 읽을 거라고 믿으면 안 된다. LSP 는 편집기
311
+ * 안의 **안 저장된 내용**까지 다루려고 만든 규약이라, 열어서 보여 준 파일이
312
+ * 진짜고 그 밖은 디스크 것이다. 우리는 이미 저장한 뒤에 부르지만, 서버가
313
+ * 파일 바뀐 것을 언제 알아채는지는 서버마다 다르다 — 그래서 직접 준다.
314
+ */
315
+ 보여주기(abs, 글 = null) {
316
+ const uri = pathToFileURL(abs).href;
317
+ let 내용 = 글;
318
+ if (내용 == null) {
319
+ try { 내용 = readFileSync(abs, 'utf8'); } catch { return null; }
320
+ }
321
+ const 판 = (this.연것.get(uri) ?? 0) + 1;
322
+ this.연것.set(uri, 판);
323
+ if (판 === 1) {
324
+ this.알림('textDocument/didOpen', {
325
+ textDocument: { uri, languageId: 언어아이디(abs), version: 판, text: 내용 },
326
+ });
327
+ } else {
328
+ this.알림('textDocument/didChange', {
329
+ textDocument: { uri, version: 판 },
330
+ contentChanges: [{ text: 내용 }], // 통째로 보낸다. 조각 계산은 틀릴 자리가 많다
331
+ });
332
+ }
333
+ return uri;
334
+ }
335
+
336
+ /**
337
+ * 이 파일의 진단을 기다린다.
338
+ *
339
+ * 안 오면 안 온 대로 null 을 준다 — **없다고 하지 않는다.** 그 차이가 크다.
340
+ * 늦게 오는 것을 '오류 없음' 으로 바꿔 말하면, 이 프로그램이 확인 안 한 것을
341
+ * 확인했다고 하는 셈이 된다. 그건 여기서 제일 하면 안 되는 일이다.
342
+ */
343
+ async 진단기다리기(uri, 시한 = 2500) {
344
+ const 끝 = Date.now() + 시한;
345
+ // 진단은 '바뀐 뒤' 것을 봐야 한다. 판을 올린 순간 옛 진단을 지워 두면
346
+ // 새것이 올 때까지 기다리게 된다.
347
+ const 열쇠 = 열쇠주소(uri);
348
+ this.진단.delete(열쇠);
349
+ this.기다리는진단++;
350
+ this.#잡기();
351
+ try {
352
+ for (;;) {
353
+ if (this.진단.has(열쇠)) return this.진단.get(열쇠);
354
+ if (Date.now() >= 끝 || !this.살았나()) return null;
355
+ await new Promise((r) => { const t = setTimeout(r, 60); t.unref?.(); });
356
+ }
357
+ } finally {
358
+ this.기다리는진단 = Math.max(0, this.기다리는진단 - 1);
359
+ this.#놓기();
360
+ }
361
+ }
362
+
363
+ // ── 끄기 ──────────────────────────────────────────────────────────────
364
+
365
+ #놀림다시() {
366
+ clearTimeout(this.놀림시계);
367
+ this.놀림시계 = setTimeout(() => { this.끄기(); }, 놀림시한);
368
+ this.놀림시계.unref?.();
369
+ }
370
+
371
+ async 끄기() {
372
+ clearTimeout(this.놀림시계);
373
+ if (!this.아이) return;
374
+ this.끄는중 = true;
375
+ this.능력.ready = false;
376
+ try {
377
+ // 예의는 갖추되 오래 기다리지 않는다. 안 나가면 그냥 끊는다.
378
+ const 갔나 = await Promise.race([
379
+ this.#물음('shutdown', null, 1500).then(() => { this.알림('exit', null); return true; }),
380
+ new Promise((r) => { const t = setTimeout(() => r(false), 1600); t.unref?.(); }),
381
+ ]);
382
+ void 갔나;
383
+ } catch { /* 끄다 나는 탈은 삼킨다 */ }
384
+ try { this.아이.kill(); } catch { /* 이미 갔다 */ }
385
+ this.아이 = null;
386
+ this.기다림.clear();
387
+ this.끄는중 = false;
388
+ }
389
+ }
390
+
391
+ // ── 여러 언어를 동시에 ────────────────────────────────────────────────────
392
+ //
393
+ // 한 프로젝트에 ts 와 py 가 같이 있는 것이 드물지 않다. 언어마다 서버가 따로라
394
+ // 뿌리+언어를 열쇠로 잡아 둔다. 켤 때 값이 들어서 한 번 켠 것은 계속 쓴다.
395
+
396
+ const 풀 = new Map();
397
+ const 열쇠 = (뿌리, g) => `${뿌리}${g}`;
398
+
399
+ /**
400
+ * 이 파일에 맞는 서버를 얻는다. 못 쓰면 null.
401
+ * @param 켜기까지 false 면 이미 켜진 것만 준다 — 편집 뒤 진단이 이 길로 온다.
402
+ */
403
+ export async function 얻기(뿌리, 파일, { 켜기까지 = true, env = process.env } = {}) {
404
+ const g = 갈래(파일);
405
+ if (!g) return null;
406
+ const k = 열쇠(뿌리, g);
407
+ let 것 = 풀.get(k);
408
+ if (!것) {
409
+ if (!켜기까지) return null;
410
+ const 고른 = 고르기(g, env);
411
+ if (!고른) return null;
412
+ 것 = new 언어서버(뿌리, 고른);
413
+ 풀.set(k, 것);
414
+ }
415
+ if (것.죽음) return null;
416
+ if (!켜기까지) return 것.능력.ready ? 것 : null;
417
+ return await 것.켜기() ? 것 : null;
418
+ }
419
+
420
+ /** 지금 떠 있는 것들. /lsp 화면과 시험이 쓴다. */
421
+ export function 지금것들() {
422
+ return [...풀.entries()].map(([k, v]) => {
423
+ const [뿌리, 갈래열쇠] = k.split('');
424
+ return { 뿌리, 갈래: 갈래열쇠, 이름: v.서버?.이름, 살았나: v.살았나(), 준비: !!v.능력.ready, 죽음: v.죽음 ?? null };
425
+ });
426
+ }
427
+
428
+ /** 다 끈다. 프로그램이 끝날 때와 시험 뒤에 부른다. */
429
+ export async function 모두끄기() {
430
+ const 것들 = [...풀.values()];
431
+ 풀.clear();
432
+ await Promise.all(것들.map((v) => v.끄기().catch(() => {})));
433
+ }
434
+
435
+ /**
436
+ * 부모가 죽을 때 아이도 같이 데려간다.
437
+ *
438
+ * 안 그러면 사용자 컴퓨터에 언어 서버가 하나씩 쌓인다. 이건 사용자가 나중에
439
+ * 작업 관리자를 열기 전에는 모르는 종류의 탈이라, 반드시 여기서 막아야 한다.
440
+ */
441
+ for (const 신호 of ['exit', 'SIGINT', 'SIGTERM']) {
442
+ process.on(신호, () => {
443
+ for (const v of 풀.values()) { try { v.아이?.kill(); } catch { /* 이미 갔다 */ } }
444
+ 풀.clear();
445
+ });
446
+ }
447
+
448
+ /**
449
+ * 켠 지 얼마 안 됐나.
450
+ *
451
+ * 언어 서버는 악수를 마친 **뒤에** 프로젝트를 훑는다. 그 사이에 이름을 물으면
452
+ * 빈손으로 온다 — 없어서가 아니라 아직 못 봐서다. 이 둘을 구별 못 하면
453
+ * "그런 이름 없습니다" 라고 잘라 말하게 되고, 모델은 그 말을 믿고 새로 만든다.
454
+ */
455
+ export function 색인중일까(서버) {
456
+ return !!서버?.켜진때 && Date.now() - 서버.켜진때 < 30_000;
457
+ }
458
+
459
+ export { fileURLToPath as 경로로, 열쇠주소 };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 고친 직후에 그 파일이 성한지 본다.
3
+ *
4
+ * ── 왜 Verify 로 안 되나 ────────────────────────────────────────────────
5
+ *
6
+ * Verify 는 남는다. 여기가 하는 일은 다르다 —
7
+ *
8
+ * Verify 일 다 끝내고 한 번. `node --check` 라 **문법**만 본다.
9
+ * 이쪽 고친 직후 그 파일만. 언어 서버라 **뜻**까지 본다.
10
+ *
11
+ * 문법은 맞는데 틀린 것들이 있다. 없는 이름을 부르는 것, 인자를 하나 빠뜨린
12
+ * 것, 오타 난 속성. node --check 는 다 통과시킨다. 이런 것은 지금까지 돌려
13
+ * 봐야 나왔고, 돌려 보는 것은 사용자 승인을 거치는 일이라 몇 걸음 뒤였다.
14
+ *
15
+ * 그 몇 걸음이 비싸다. 그 사이에 모델은 그 파일을 고친 것으로 치고 다음 파일을
16
+ * 고치기 시작한다. 나중에 오류가 나오면 어디서부터 잘못됐는지를 되짚어야 하고,
17
+ * 되짚는 값이 고치는 값보다 크다.
18
+ *
19
+ * ── 왜 여기서 서버를 안 켜나 ───────────────────────────────────────────
20
+ *
21
+ * 켜는 데 몇 초가 든다. 고칠 때마다 그걸 물면 이 프로그램이 느려진 것으로
22
+ * 보이고, 그건 얻는 것보다 크다. 그래서 **이미 떠 있는 서버만** 쓴다.
23
+ * 대신 처음 고칠 때 뒤에서 하나 데워 둔다(데우기). 첫 파일은 못 받고 그다음
24
+ * 부터 받는데, 어차피 한 파일만 고치고 끝나는 일에는 이게 필요 없다.
25
+ *
26
+ * 조용히 실패하는 쪽으로 만든다. 진단을 못 받은 것과 오류가 없는 것은 다르고,
27
+ * 그 둘을 섞으면 이 프로그램이 확인 안 한 것을 확인했다고 말하게 된다.
28
+ * 못 받았으면 아무 말도 안 한다 — 없는 말을 지어내는 것보다 낫다.
29
+ */
30
+ import { pathToFileURL } from 'node:url';
31
+ import { 얻기 } from './client.js';
32
+ import { 갈래 } from './servers.js';
33
+
34
+ // 고친 뒤 이만큼만 기다린다. 사람이 화면을 보고 있는 시간이다.
35
+ const 기다릴시한 = 1800;
36
+ // 화면과 모델에 실을 줄 수. 다 실으면 고친 결과보다 진단이 길어진다.
37
+ const 보일수 = 6;
38
+
39
+ const 심각도 = { 1: '오류', 2: '경고', 3: '알림', 4: '힌트' };
40
+
41
+ /**
42
+ * 뒤에서 서버 하나를 데워 둔다. 기다리지 않는다.
43
+ *
44
+ * 던지지 않는 것이 중요하다. 여기서 나는 탈이 편집을 실패로 만들면 안 된다 —
45
+ * 파일은 이미 고쳐졌고, 고쳐졌는지 아닌지 모르는 상태가 제일 나쁘다.
46
+ */
47
+ export function 데우기(뿌리, 파일) {
48
+ try {
49
+ if (!갈래(파일)) return;
50
+ Promise.resolve(얻기(뿌리, 파일)).catch(() => {});
51
+ } catch { /* 데우다 나는 탈은 삼킨다 */ }
52
+ }
53
+
54
+ /**
55
+ * 고친 파일의 진단을 가져온다.
56
+ *
57
+ * @returns {{오류:number, 경고:number, 글:string}|null} 못 받았으면 null
58
+ */
59
+ export async function 편집후진단(뿌리, abs, { 시한 = 기다릴시한 } = {}) {
60
+ try {
61
+ if (!갈래(abs)) return null;
62
+ // 이미 떠 있는 것만. 여기서 켜면 편집이 몇 초씩 멎는다.
63
+ const 서버 = await 얻기(뿌리, abs, { 켜기까지: false });
64
+ if (!서버) { 데우기(뿌리, abs); return null; }
65
+
66
+ const uri = 서버.보여주기(abs);
67
+ if (!uri) return null;
68
+ const 것들 = await 서버.진단기다리기(uri, 시한);
69
+ if (!Array.isArray(것들)) return null; // 안 왔다 ≠ 없다
70
+
71
+ const 오류 = 것들.filter((d) => (d.severity ?? 1) === 1);
72
+ const 경고 = 것들.filter((d) => d.severity === 2);
73
+ if (!오류.length && !경고.length) return { 오류: 0, 경고: 0, 글: '' };
74
+
75
+ // 오류를 먼저, 그다음 경고. 줄 번호 순.
76
+ const 실을것 = [...오류, ...경고]
77
+ .sort((a, b) => (a.severity ?? 1) - (b.severity ?? 1) || (a.range?.start?.line ?? 0) - (b.range?.start?.line ?? 0))
78
+ .slice(0, 보일수);
79
+ const 줄들 = 실을것.map((d) => {
80
+ const 줄 = (d.range?.start?.line ?? 0) + 1;
81
+ const 갈 = 심각도[d.severity ?? 1] ?? '오류';
82
+ const 말 = String(d.message ?? '').split('\n')[0].slice(0, 200);
83
+ return ` ${줄}줄 ${갈}: ${말}`;
84
+ });
85
+ const 남은 = (오류.length + 경고.length) - 실을것.length;
86
+ if (남은 > 0) 줄들.push(` … ${남은}개 더`);
87
+
88
+ return { 오류: 오류.length, 경고: 경고.length, 글: 줄들.join('\n') };
89
+ } catch {
90
+ return null; // 진단 보다 터져서 편집이 실패로 보이는 일은 없어야 한다
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 도구 결과에 진단 한 토막을 붙인다.
96
+ *
97
+ * 붙이는 것은 **탈이 있을 때뿐**이다. 아무 문제 없을 때 "오류 0개" 를 붙이면
98
+ * 고칠 때마다 한 줄씩 늘어나고, 창은 그만큼씩 찬다. 그리고 조용한 것이
99
+ * 곧 성했다는 뜻이 되게 두는 편이 읽기 쉽다.
100
+ */
101
+ export function 붙이기(결과, 진단, 보일이름) {
102
+ if (!진단 || (!진단.오류 && !진단.경고)) return 결과;
103
+ const 머리 = `언어 서버 — ${보일이름}: `
104
+ + [진단.오류 ? `오류 ${진단.오류}개` : '', 진단.경고 ? `경고 ${진단.경고}개` : ''].filter(Boolean).join(' · ');
105
+ return {
106
+ ...결과,
107
+ diagnostics: { errors: 진단.오류, warnings: 진단.경고 },
108
+ summary: `${결과.summary ?? ''}\n${머리}\n${진단.글}`.trim(),
109
+ };
110
+ }
111
+
112
+ export { pathToFileURL as 주소로 };
package/src/lsp/rpc.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * LSP 말틀 — Content-Length 로 감싼 JSON-RPC 를 만들고 푼다.
3
+ *
4
+ * ── 왜 직접 쓰나 ────────────────────────────────────────────────────────
5
+ *
6
+ * 이 프로그램은 의존성이 0개다. 사내에 미승인 SW 를 못 들이기 때문이고,
7
+ * 그건 못 바꾼다. vscode-jsonrpc 를 받아 오면 그 규칙이 깨진다.
8
+ *
9
+ * 다행히 이 틀은 작다. 머리말 한 줄과 빈 줄, 그 다음이 JSON 이다.
10
+ * 실제로 어려운 것은 **틀 자체가 아니라 경계**다. 아래 두 가지 때문에 —
11
+ *
12
+ * 1) Content-Length 는 글자 수가 아니라 **바이트 수**다. 한글이 든 파일
13
+ * 이름 하나만 섞여도 두 값이 갈린다. 글자로 세면 그 순간부터 한 칸씩
14
+ * 밀린 채로 읽는데, 그러면 바로 안 죽고 **몇 통 뒤에** 죽는다.
15
+ * 2) 파이프는 통 단위로 안 온다. 머리말 한가운데서 끊기기도 하고, 세 통이
16
+ * 한 덩어리로 붙어 오기도 한다. 그래서 받는 쪽은 통 하나가 다 찰 때까지
17
+ * 들고 있다가, 찬 것만 내주고 나머지를 남겨야 한다.
18
+ *
19
+ * 그래서 이 파일은 순수 함수로만 두고 따로 시험한다. 프로세스를 안 띄우고도
20
+ * 위 두 가지를 다 재 볼 수 있어야 하기 때문이다 — 언어 서버가 안 깔린
21
+ * 자리(사내망이 대개 그렇다)에서도 이 부분은 그대로 시험이 돈다.
22
+ */
23
+
24
+ /**
25
+ * 보낼 통 하나를 만든다.
26
+ * @returns {Buffer} 머리말까지 붙은 통. 그대로 stdin 에 쓰면 된다.
27
+ */
28
+ export function 틀(obj) {
29
+ const 몸 = Buffer.from(JSON.stringify(obj), 'utf8');
30
+ // 길이는 바이트다. 여기서 몸.length 를 쓰는 것이 핵심 — 문자열의 .length 를
31
+ // 쓰면 한글 한 자마다 두 바이트씩 모자라게 적히고, 받는 쪽이 밀린다.
32
+ return Buffer.concat([Buffer.from(`Content-Length: ${몸.length}\r\n\r\n`, 'ascii'), 몸]);
33
+ }
34
+
35
+ /**
36
+ * 받는 쪽. 오는 대로 넣으면 다 찬 통만 골라 내준다.
37
+ *
38
+ * 깨진 JSON 하나 때문에 대화 전체를 버리지 않는다. 그 통만 버리고 다음으로
39
+ * 넘어간다 — 언어 서버는 제 나름의 확장 알림을 보내기도 하는데, 그중 하나를
40
+ * 못 읽는다고 나머지 답까지 못 받게 되면 도구가 통째로 멎는다.
41
+ */
42
+ export function 받개() {
43
+ let 남은 = Buffer.alloc(0);
44
+ const 버린것 = [];
45
+
46
+ return {
47
+ /** @returns {object[]} 이번에 다 찬 통들 */
48
+ 넣기(덩어리) {
49
+ 남은 = Buffer.concat([남은, Buffer.isBuffer(덩어리) ? 덩어리 : Buffer.from(덩어리)]);
50
+ const 나온것 = [];
51
+
52
+ for (;;) {
53
+ const 끝 = 남은.indexOf('\r\n\r\n');
54
+ if (끝 < 0) break; // 머리말이 아직 안 끝났다
55
+ const 머리 = 남은.subarray(0, 끝).toString('ascii');
56
+ const 잰것 = /content-length:\s*(\d+)/i.exec(머리);
57
+ if (!잰것) {
58
+ // 길이를 안 적어 보냈다. 어디까지가 한 통인지 알 길이 없으므로
59
+ // 여기까지 버리고 다음 경계부터 다시 맞춘다.
60
+ 버린것.push('길이 없는 머리말');
61
+ 남은 = 남은.subarray(끝 + 4);
62
+ continue;
63
+ }
64
+ const 길이 = Number(잰것[1]);
65
+ const 몸시작 = 끝 + 4;
66
+ if (남은.length < 몸시작 + 길이) break; // 몸이 아직 다 안 왔다
67
+
68
+ const 몸 = 남은.subarray(몸시작, 몸시작 + 길이).toString('utf8');
69
+ 남은 = 남은.subarray(몸시작 + 길이);
70
+ try {
71
+ 나온것.push(JSON.parse(몸));
72
+ } catch {
73
+ // 이 통만 버린다. 경계는 길이로 이미 맞춰 놨으니 다음 통은 멀쩡하다.
74
+ 버린것.push(몸.slice(0, 80));
75
+ }
76
+ }
77
+ return 나온것;
78
+ },
79
+
80
+ /** 아직 통이 안 찬 채로 들고 있는 바이트 수. 시험과 진단용. */
81
+ get 들고있는것() { return 남은.length; },
82
+ get 버린수() { return 버린것.length; },
83
+ };
84
+ }