godot-daedalus_backend 1.0.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/README.md +101 -0
- package/bin/godot-daedalus-backend.js +4 -0
- package/bin/godot-daedalus-mcp.js +4 -0
- package/bin/godot-daedalus-terminal-mcp.js +4 -0
- package/bin/run-tsx-entry.js +26 -0
- package/package.json +54 -0
- package/scripts/deepseek-tokenizer-server.py +54 -0
- package/src/app-paths.ts +36 -0
- package/src/main.ts +21 -0
- package/src/mcp/content-length-protocol.ts +68 -0
- package/src/mcp/custom-mcp-config-store.ts +397 -0
- package/src/mcp/godot-diagnostics-bridge.ts +1298 -0
- package/src/mcp/godot-editor-bridge.ts +307 -0
- package/src/mcp/godot-mcp-server.ts +3484 -0
- package/src/mcp/godot-paths.ts +151 -0
- package/src/mcp/godot-project-settings.ts +233 -0
- package/src/mcp/godot-tool-registration.ts +46 -0
- package/src/mcp/mcp-config.ts +48 -0
- package/src/mcp/mcp-host.ts +393 -0
- package/src/mcp/mcp-session.ts +81 -0
- package/src/mcp/terminal-mcp-server.ts +576 -0
- package/src/mcp/tscn-tools.ts +302 -0
- package/src/mcp/types.ts +12 -0
- package/src/ping-client.ts +24 -0
- package/src/prompts/registry.ts +97 -0
- package/src/prompts/templates/backend-helper.md +25 -0
- package/src/prompts/templates/gdscript-reviewer.md +19 -0
- package/src/prompts/templates/godot-assistant.md +225 -0
- package/src/prompts/templates/scene-architect.md +15 -0
- package/src/prompts/templates/session-compressor.md +33 -0
- package/src/protocol/schema.ts +486 -0
- package/src/protocol/types.ts +77 -0
- package/src/providers/deepseek-agent.ts +1014 -0
- package/src/providers/deepseek-client.ts +114 -0
- package/src/providers/deepseek-dsml-tools.ts +90 -0
- package/src/providers/deepseek-loose-tools.ts +450 -0
- package/src/providers/provider-config-store.ts +164 -0
- package/src/server/client-session.ts +93 -0
- package/src/server/request-dispatcher.ts +74 -0
- package/src/server/response-helpers.ts +33 -0
- package/src/server/send-json.ts +8 -0
- package/src/server/websocket-server.ts +3997 -0
- package/src/session/session-compressor.ts +68 -0
- package/src/session/session-store.ts +669 -0
- package/src/skills/registry.ts +180 -0
- package/src/skills/templates/backend-helper.md +12 -0
- package/src/skills/templates/file-creator.md +14 -0
- package/src/skills/templates/gdscript-review.md +12 -0
- package/src/skills/templates/godot-project-init.md +29 -0
- package/src/skills/templates/scene-builder.md +12 -0
- package/src/tokens/deepseek-tokenizer-counter.ts +233 -0
- package/src/tokens/model-profiles.ts +38 -0
- package/src/tokens/token-counter-factory.ts +52 -0
- package/src/tokens/token-counter.ts +22 -0
- package/src/tools/approval-gateway.ts +111 -0
- package/src/tools/llm-tools.ts +1415 -0
- package/src/tools/tool-dispatcher.ts +147 -0
- package/src/tools/tool-event-describer.ts +387 -0
- package/src/tools/tool-idempotency.ts +373 -0
- package/src/tools/tool-policy-table.ts +61 -0
- package/src/tools/tool-policy.ts +73 -0
- package/src/workflow/llm-planner.ts +407 -0
- package/src/workflow/planner.ts +201 -0
- package/src/workflow/runner.ts +141 -0
- package/src/workflow/types.ts +69 -0
- package/src/workspace/registry.ts +104 -0
- package/src/workspace/types.ts +7 -0
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { ContentLengthMessageParser, encodeContentLengthMessage } from "./content-length-protocol.js";
|
|
6
|
+
import type { WorkspaceConfig } from "../workspace/types.js";
|
|
7
|
+
|
|
8
|
+
export const GODOT_DIAGNOSTICS_SERVER_ID: string = "godot_diagnostics";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_LSP_HOST: string = "127.0.0.1";
|
|
11
|
+
const DEFAULT_LSP_PORT: number = 6005;
|
|
12
|
+
const DEFAULT_DAP_HOST: string = "127.0.0.1";
|
|
13
|
+
const DEFAULT_DAP_PORT: number = 6006;
|
|
14
|
+
const TCP_CONNECT_TIMEOUT_MS: number = 1200;
|
|
15
|
+
const REQUEST_TIMEOUT_MS: number = 3000;
|
|
16
|
+
const DIAGNOSTICS_WAIT_MS: number = 1200;
|
|
17
|
+
const DAP_EVENT_WAIT_MS: number = 300;
|
|
18
|
+
const MAX_DIAGNOSTICS: number = 100;
|
|
19
|
+
const MAX_SYMBOLS: number = 120;
|
|
20
|
+
const MAX_STACK_FRAMES: number = 30;
|
|
21
|
+
const MAX_VARIABLES: number = 80;
|
|
22
|
+
const MAX_TEXT_LENGTH: number = 1000;
|
|
23
|
+
|
|
24
|
+
type JsonObject = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
type ToolTextResult = {
|
|
27
|
+
content: Array<{
|
|
28
|
+
type: "text";
|
|
29
|
+
text: string;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Endpoint = {
|
|
34
|
+
host: string;
|
|
35
|
+
port: number;
|
|
36
|
+
source: "editor_settings" | "default";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type DiagnosticsConfig = {
|
|
40
|
+
lsp: Endpoint;
|
|
41
|
+
dap: Endpoint;
|
|
42
|
+
editorSettingsFile: string | null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type CachedEndpointStatus = {
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
source: string;
|
|
49
|
+
available: boolean | null;
|
|
50
|
+
lastCheckedAt: string | null;
|
|
51
|
+
lastError: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PendingJsonRpcRequest = {
|
|
55
|
+
resolve: (value: unknown) => void;
|
|
56
|
+
reject: (error: Error) => void;
|
|
57
|
+
timeout: NodeJS.Timeout;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type PendingDapRequest = {
|
|
61
|
+
resolve: (value: DapResponse) => void;
|
|
62
|
+
reject: (error: Error) => void;
|
|
63
|
+
timeout: NodeJS.Timeout;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type DapResponse = {
|
|
67
|
+
type: "response";
|
|
68
|
+
request_seq: number;
|
|
69
|
+
command: string;
|
|
70
|
+
success: boolean;
|
|
71
|
+
message?: string;
|
|
72
|
+
body?: unknown;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type ResolvedResourcePath = {
|
|
76
|
+
resourcePath: string;
|
|
77
|
+
absolutePath: string;
|
|
78
|
+
uri: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type ConfigEntry = {
|
|
82
|
+
fullKey: string;
|
|
83
|
+
valueExpression: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function jsonTextResult(value: unknown): ToolTextResult {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: JSON.stringify(value, null, 2)
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isRecord(value: unknown): value is JsonObject {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clipText(value: unknown, maxLength: number = MAX_TEXT_LENGTH): string {
|
|
102
|
+
const text: string = typeof value === "string" ? value : JSON.stringify(value);
|
|
103
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getWindowsAppDataPath(): string | null {
|
|
107
|
+
const appDataPath: string | undefined = process.env.APPDATA;
|
|
108
|
+
if (appDataPath === undefined || appDataPath.trim().length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return appDataPath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getGodotConfigDir(): string | null {
|
|
116
|
+
const appDataPath: string | null = getWindowsAppDataPath();
|
|
117
|
+
return appDataPath === null ? null : path.join(appDataPath, "Godot");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseConfigEntries(content: string): ConfigEntry[] {
|
|
121
|
+
const lines: string[] = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
122
|
+
const entries: ConfigEntry[] = [];
|
|
123
|
+
let currentSection: string = "";
|
|
124
|
+
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const trimmedLine: string = line.trim();
|
|
127
|
+
const sectionMatch: RegExpMatchArray | null = trimmedLine.match(/^\[([^\]]+)\]$/);
|
|
128
|
+
if (sectionMatch !== null) {
|
|
129
|
+
currentSection = sectionMatch[1]!.trim();
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (trimmedLine.length === 0 || trimmedLine.startsWith(";")) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const equalsIndex: number = line.indexOf("=");
|
|
138
|
+
if (equalsIndex < 0) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const name: string = line.slice(0, equalsIndex).trim();
|
|
143
|
+
if (name.length === 0) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fullKey: string = currentSection.length > 0 ? `${currentSection}/${name}` : name;
|
|
148
|
+
entries.push({
|
|
149
|
+
fullKey,
|
|
150
|
+
valueExpression: line.slice(equalsIndex + 1).trim()
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createConfigMap(entries: ConfigEntry[]): Map<string, string> {
|
|
158
|
+
return new Map(entries.map((entry: ConfigEntry): [string, string] => [entry.fullKey, entry.valueExpression]));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseStringExpression(valueExpression: string | undefined): string | undefined {
|
|
162
|
+
if (valueExpression === undefined) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const trimmedValue: string = valueExpression.trim();
|
|
167
|
+
if (trimmedValue.startsWith("\"") && trimmedValue.endsWith("\"")) {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(trimmedValue) as string;
|
|
170
|
+
} catch {
|
|
171
|
+
return trimmedValue.slice(1, -1);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return trimmedValue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseIntegerExpression(valueExpression: string | undefined, fallback: number): number {
|
|
179
|
+
const trimmedValue: string | undefined = valueExpression?.trim();
|
|
180
|
+
if (trimmedValue === undefined || !/^\d+$/.test(trimmedValue)) {
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const parsedValue: number = Number.parseInt(trimmedValue, 10);
|
|
185
|
+
return parsedValue >= 1 && parsedValue <= 65535 ? parsedValue : fallback;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function findEditorSettingsFile(projectRoot: string): Promise<string | null> {
|
|
189
|
+
const configDir: string | null = getGodotConfigDir();
|
|
190
|
+
if (configDir === null) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let preferredVersion: string | null = null;
|
|
195
|
+
try {
|
|
196
|
+
const projectConfig: string = await fs.readFile(path.join(projectRoot, "project.godot"), "utf8");
|
|
197
|
+
const projectConfigMap: Map<string, string> = createConfigMap(parseConfigEntries(projectConfig));
|
|
198
|
+
const featuresExpression: string | undefined = projectConfigMap.get("application/config/features") ?? projectConfigMap.get("config/features");
|
|
199
|
+
const match: RegExpMatchArray | null = featuresExpression?.match(/"(\d+\.\d+)"/) ?? null;
|
|
200
|
+
preferredVersion = match?.[1] ?? null;
|
|
201
|
+
} catch {
|
|
202
|
+
preferredVersion = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let fileNames: string[];
|
|
206
|
+
try {
|
|
207
|
+
fileNames = await fs.readdir(configDir);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const settingsFiles: Array<{ fileName: string; version: string; major: number; minor: number }> = [];
|
|
213
|
+
for (const fileName of fileNames) {
|
|
214
|
+
const match: RegExpMatchArray | null = fileName.match(/^editor_settings-(\d+)(?:\.(\d+))?\.tres$/);
|
|
215
|
+
if (match === null) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const major: number = Number.parseInt(match[1]!, 10);
|
|
220
|
+
const minor: number = match[2] === undefined ? -1 : Number.parseInt(match[2], 10);
|
|
221
|
+
settingsFiles.push({
|
|
222
|
+
fileName,
|
|
223
|
+
version: minor < 0 ? `${major}` : `${major}.${minor}`,
|
|
224
|
+
major,
|
|
225
|
+
minor
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
settingsFiles.sort((left, right): number => {
|
|
230
|
+
if (right.major !== left.major) {
|
|
231
|
+
return right.major - left.major;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return right.minor - left.minor;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const selected = settingsFiles.find((file): boolean => file.version === preferredVersion) ?? settingsFiles[0];
|
|
238
|
+
return selected === undefined ? null : path.join(configDir, selected.fileName);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function resolveDiagnosticsConfig(workspace: WorkspaceConfig): Promise<DiagnosticsConfig> {
|
|
242
|
+
const editorSettingsFile: string | null = await findEditorSettingsFile(workspace.rootPath);
|
|
243
|
+
let editorSettings: Map<string, string> = new Map();
|
|
244
|
+
|
|
245
|
+
if (editorSettingsFile !== null) {
|
|
246
|
+
try {
|
|
247
|
+
const content: string = await fs.readFile(editorSettingsFile, "utf8");
|
|
248
|
+
editorSettings = createConfigMap(parseConfigEntries(content));
|
|
249
|
+
} catch {
|
|
250
|
+
editorSettings = new Map();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lspHost: string = parseStringExpression(editorSettings.get("network/language_server/remote_host")) ?? DEFAULT_LSP_HOST;
|
|
255
|
+
const lspPort: number = parseIntegerExpression(editorSettings.get("network/language_server/remote_port"), DEFAULT_LSP_PORT);
|
|
256
|
+
const dapPort: number = parseIntegerExpression(editorSettings.get("network/debug_adapter/remote_port"), DEFAULT_DAP_PORT);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
lsp: {
|
|
260
|
+
host: lspHost,
|
|
261
|
+
port: lspPort,
|
|
262
|
+
source: editorSettings.has("network/language_server/remote_port") || editorSettings.has("network/language_server/remote_host") ? "editor_settings" : "default"
|
|
263
|
+
},
|
|
264
|
+
dap: {
|
|
265
|
+
host: DEFAULT_DAP_HOST,
|
|
266
|
+
port: dapPort,
|
|
267
|
+
source: editorSettings.has("network/debug_adapter/remote_port") ? "editor_settings" : "default"
|
|
268
|
+
},
|
|
269
|
+
editorSettingsFile
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function ensureWorkspaceResourcePath(workspace: WorkspaceConfig, inputPath: string): ResolvedResourcePath {
|
|
274
|
+
const trimmedPath: string = inputPath.trim();
|
|
275
|
+
if (trimmedPath.length === 0) {
|
|
276
|
+
throw new Error("resourcePath is required");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const projectRoot: string = path.resolve(workspace.rootPath);
|
|
280
|
+
let absolutePath: string;
|
|
281
|
+
|
|
282
|
+
if (trimmedPath.startsWith("res://")) {
|
|
283
|
+
const relativePath: string = trimmedPath.slice("res://".length).replace(/^[/\\]+/, "");
|
|
284
|
+
absolutePath = path.resolve(projectRoot, relativePath);
|
|
285
|
+
} else if (path.isAbsolute(trimmedPath)) {
|
|
286
|
+
absolutePath = path.resolve(trimmedPath);
|
|
287
|
+
} else {
|
|
288
|
+
absolutePath = path.resolve(projectRoot, trimmedPath);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const relativePath: string = path.relative(projectRoot, absolutePath);
|
|
292
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
293
|
+
throw new Error(`resourcePath is outside the Godot project: ${inputPath}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const normalizedRelativePath: string = relativePath.split(path.sep).join("/");
|
|
297
|
+
return {
|
|
298
|
+
resourcePath: `res://${normalizedRelativePath}`,
|
|
299
|
+
absolutePath,
|
|
300
|
+
uri: pathToFileURL(absolutePath).href
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function uriToResourcePath(workspace: WorkspaceConfig, uri: unknown): string | null {
|
|
305
|
+
if (typeof uri !== "string" || !uri.startsWith("file:")) {
|
|
306
|
+
return typeof uri === "string" ? uri : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const absolutePath: string = fileURLToPath(uri);
|
|
311
|
+
const projectRoot: string = path.resolve(workspace.rootPath);
|
|
312
|
+
const relativePath: string = path.relative(projectRoot, absolutePath);
|
|
313
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
314
|
+
return uri;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return `res://${relativePath.split(path.sep).join("/")}`;
|
|
318
|
+
} catch {
|
|
319
|
+
return uri;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function connectTcp(endpoint: Endpoint): Promise<net.Socket> {
|
|
324
|
+
return await new Promise<net.Socket>((resolve, reject): void => {
|
|
325
|
+
const socket: net.Socket = net.createConnection({ host: endpoint.host, port: endpoint.port });
|
|
326
|
+
let finished: boolean = false;
|
|
327
|
+
const timeout: NodeJS.Timeout = setTimeout((): void => {
|
|
328
|
+
if (finished) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
finished = true;
|
|
333
|
+
socket.destroy();
|
|
334
|
+
reject(new Error(`connection_timeout: ${endpoint.host}:${endpoint.port}`));
|
|
335
|
+
}, TCP_CONNECT_TIMEOUT_MS);
|
|
336
|
+
|
|
337
|
+
socket.once("connect", (): void => {
|
|
338
|
+
if (finished) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
finished = true;
|
|
343
|
+
clearTimeout(timeout);
|
|
344
|
+
socket.setNoDelay(true);
|
|
345
|
+
resolve(socket);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
socket.once("error", (error: Error): void => {
|
|
349
|
+
if (finished) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
finished = true;
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
reject(error);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class JsonRpcPeer {
|
|
361
|
+
private readonly socket: net.Socket;
|
|
362
|
+
private readonly parser: ContentLengthMessageParser = new ContentLengthMessageParser();
|
|
363
|
+
private readonly pendingRequests: Map<number, PendingJsonRpcRequest> = new Map();
|
|
364
|
+
private readonly notifications: JsonObject[] = [];
|
|
365
|
+
private nextId: number = 1;
|
|
366
|
+
|
|
367
|
+
constructor(socket: net.Socket) {
|
|
368
|
+
this.socket = socket;
|
|
369
|
+
this.socket.on("data", (chunk: Buffer): void => {
|
|
370
|
+
for (const message of this.parser.push(chunk)) {
|
|
371
|
+
this.handleMessage(message);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
this.socket.on("error", (error: Error): void => this.rejectAll(error));
|
|
375
|
+
this.socket.on("close", (): void => this.rejectAll(new Error("connection_closed")));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async request(method: string, params: JsonObject = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<unknown> {
|
|
379
|
+
const id: number = this.nextId;
|
|
380
|
+
this.nextId += 1;
|
|
381
|
+
const payload: JsonObject = {
|
|
382
|
+
jsonrpc: "2.0",
|
|
383
|
+
id,
|
|
384
|
+
method,
|
|
385
|
+
params
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return await new Promise<unknown>((resolve, reject): void => {
|
|
389
|
+
const timeout: NodeJS.Timeout = setTimeout((): void => {
|
|
390
|
+
this.pendingRequests.delete(id);
|
|
391
|
+
reject(new Error(`request_timeout: ${method}`));
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
394
|
+
this.socket.write(encodeContentLengthMessage(payload));
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
notify(method: string, params: JsonObject = {}): void {
|
|
399
|
+
this.socket.write(encodeContentLengthMessage({
|
|
400
|
+
jsonrpc: "2.0",
|
|
401
|
+
method,
|
|
402
|
+
params
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async waitForNotification(predicate: (message: JsonObject) => boolean, timeoutMs: number): Promise<JsonObject | null> {
|
|
407
|
+
const existing: JsonObject | undefined = this.notifications.find(predicate);
|
|
408
|
+
if (existing !== undefined) {
|
|
409
|
+
return existing;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return await new Promise<JsonObject | null>((resolve): void => {
|
|
413
|
+
const startedAt: number = Date.now();
|
|
414
|
+
const interval: NodeJS.Timeout = setInterval((): void => {
|
|
415
|
+
const matched: JsonObject | undefined = this.notifications.find(predicate);
|
|
416
|
+
if (matched !== undefined) {
|
|
417
|
+
clearInterval(interval);
|
|
418
|
+
resolve(matched);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
423
|
+
clearInterval(interval);
|
|
424
|
+
resolve(null);
|
|
425
|
+
}
|
|
426
|
+
}, 25);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
close(): void {
|
|
431
|
+
this.socket.end();
|
|
432
|
+
this.socket.destroy();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private handleMessage(message: unknown): void {
|
|
436
|
+
if (!isRecord(message)) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const idValue: unknown = message.id;
|
|
441
|
+
if (typeof idValue === "number") {
|
|
442
|
+
const pending: PendingJsonRpcRequest | undefined = this.pendingRequests.get(idValue);
|
|
443
|
+
if (pending === undefined) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.pendingRequests.delete(idValue);
|
|
448
|
+
clearTimeout(pending.timeout);
|
|
449
|
+
if (isRecord(message.error)) {
|
|
450
|
+
pending.reject(new Error(clipText(message.error["message"] ?? message.error["code"] ?? "LSP request failed")));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
pending.resolve(message.result);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (typeof message.method === "string") {
|
|
459
|
+
this.notifications.push(message);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private rejectAll(error: Error): void {
|
|
464
|
+
for (const [id, pending] of this.pendingRequests.entries()) {
|
|
465
|
+
this.pendingRequests.delete(id);
|
|
466
|
+
clearTimeout(pending.timeout);
|
|
467
|
+
pending.reject(error);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
class DapPeer {
|
|
473
|
+
private readonly socket: net.Socket;
|
|
474
|
+
private readonly parser: ContentLengthMessageParser = new ContentLengthMessageParser();
|
|
475
|
+
private readonly pendingRequests: Map<number, PendingDapRequest> = new Map();
|
|
476
|
+
private readonly events: JsonObject[] = [];
|
|
477
|
+
private nextSeq: number = 1;
|
|
478
|
+
|
|
479
|
+
constructor(socket: net.Socket) {
|
|
480
|
+
this.socket = socket;
|
|
481
|
+
this.socket.on("data", (chunk: Buffer): void => {
|
|
482
|
+
for (const message of this.parser.push(chunk)) {
|
|
483
|
+
this.handleMessage(message);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
this.socket.on("error", (error: Error): void => this.rejectAll(error));
|
|
487
|
+
this.socket.on("close", (): void => this.rejectAll(new Error("connection_closed")));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async request(command: string, args: JsonObject = {}, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<DapResponse> {
|
|
491
|
+
const seq: number = this.nextSeq;
|
|
492
|
+
this.nextSeq += 1;
|
|
493
|
+
const payload: JsonObject = {
|
|
494
|
+
seq,
|
|
495
|
+
type: "request",
|
|
496
|
+
command,
|
|
497
|
+
arguments: args
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
return await new Promise<DapResponse>((resolve, reject): void => {
|
|
501
|
+
const timeout: NodeJS.Timeout = setTimeout((): void => {
|
|
502
|
+
this.pendingRequests.delete(seq);
|
|
503
|
+
reject(new Error(`request_timeout: ${command}`));
|
|
504
|
+
}, timeoutMs);
|
|
505
|
+
this.pendingRequests.set(seq, { resolve, reject, timeout });
|
|
506
|
+
this.socket.write(encodeContentLengthMessage(payload));
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async waitForEvents(timeoutMs: number): Promise<JsonObject[]> {
|
|
511
|
+
const startedLength: number = this.events.length;
|
|
512
|
+
await new Promise<void>((resolve): void => {
|
|
513
|
+
setTimeout(resolve, timeoutMs);
|
|
514
|
+
});
|
|
515
|
+
return this.events.slice(startedLength);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
getEvents(): JsonObject[] {
|
|
519
|
+
return [...this.events];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
close(): void {
|
|
523
|
+
this.socket.end();
|
|
524
|
+
this.socket.destroy();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private handleMessage(message: unknown): void {
|
|
528
|
+
if (!isRecord(message)) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (message.type === "response" && typeof message.request_seq === "number") {
|
|
533
|
+
const pending: PendingDapRequest | undefined = this.pendingRequests.get(message.request_seq);
|
|
534
|
+
if (pending === undefined) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.pendingRequests.delete(message.request_seq);
|
|
539
|
+
clearTimeout(pending.timeout);
|
|
540
|
+
pending.resolve(message as DapResponse);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (message.type === "event") {
|
|
545
|
+
this.events.push(message);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private rejectAll(error: Error): void {
|
|
550
|
+
for (const [seq, pending] of this.pendingRequests.entries()) {
|
|
551
|
+
this.pendingRequests.delete(seq);
|
|
552
|
+
clearTimeout(pending.timeout);
|
|
553
|
+
pending.reject(error);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function normalizeRange(range: unknown): JsonObject | null {
|
|
559
|
+
if (!isRecord(range) || !isRecord(range.start) || !isRecord(range.end)) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
lineStart: Number(range.start["line"] ?? 0) + 1,
|
|
565
|
+
columnStart: Number(range.start["character"] ?? 0) + 1,
|
|
566
|
+
lineEnd: Number(range.end["line"] ?? 0) + 1,
|
|
567
|
+
columnEnd: Number(range.end["character"] ?? 0) + 1
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function normalizeDiagnostics(resourcePath: string, diagnosticsValue: unknown): JsonObject[] {
|
|
572
|
+
if (!Array.isArray(diagnosticsValue)) {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return diagnosticsValue.slice(0, MAX_DIAGNOSTICS).filter(isRecord).map((diagnostic: JsonObject): JsonObject => {
|
|
577
|
+
const range: JsonObject | null = normalizeRange(diagnostic.range);
|
|
578
|
+
return {
|
|
579
|
+
resourcePath,
|
|
580
|
+
severity: severityLabel(diagnostic.severity),
|
|
581
|
+
message: clipText(diagnostic.message ?? ""),
|
|
582
|
+
code: diagnostic.code ?? null,
|
|
583
|
+
lineStart: range?.["lineStart"] ?? 1,
|
|
584
|
+
columnStart: range?.["columnStart"] ?? 1,
|
|
585
|
+
lineEnd: range?.["lineEnd"] ?? 1,
|
|
586
|
+
columnEnd: range?.["columnEnd"] ?? 1
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function severityLabel(value: unknown): string {
|
|
592
|
+
if (value === 1) {
|
|
593
|
+
return "error";
|
|
594
|
+
}
|
|
595
|
+
if (value === 2) {
|
|
596
|
+
return "warning";
|
|
597
|
+
}
|
|
598
|
+
if (value === 3) {
|
|
599
|
+
return "information";
|
|
600
|
+
}
|
|
601
|
+
if (value === 4) {
|
|
602
|
+
return "hint";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return "unknown";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function normalizeSymbols(symbolsValue: unknown): JsonObject[] {
|
|
609
|
+
if (!Array.isArray(symbolsValue)) {
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const output: JsonObject[] = [];
|
|
614
|
+
const visit = (symbols: unknown[], depth: number): void => {
|
|
615
|
+
for (const symbolValue of symbols) {
|
|
616
|
+
if (output.length >= MAX_SYMBOLS || !isRecord(symbolValue)) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const range: JsonObject | null = normalizeRange(symbolValue.range);
|
|
621
|
+
output.push({
|
|
622
|
+
name: clipText(symbolValue.name ?? "", 200),
|
|
623
|
+
kind: symbolValue.kind ?? null,
|
|
624
|
+
detail: symbolValue.detail === undefined ? null : clipText(symbolValue.detail, 400),
|
|
625
|
+
depth,
|
|
626
|
+
lineStart: range?.["lineStart"] ?? null,
|
|
627
|
+
columnStart: range?.["columnStart"] ?? null,
|
|
628
|
+
lineEnd: range?.["lineEnd"] ?? null,
|
|
629
|
+
columnEnd: range?.["columnEnd"] ?? null
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
if (Array.isArray(symbolValue.children)) {
|
|
633
|
+
visit(symbolValue.children, depth + 1);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
visit(symbolsValue, 0);
|
|
639
|
+
return output;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function normalizeLocations(workspace: WorkspaceConfig, locationValue: unknown): JsonObject[] {
|
|
643
|
+
const locations: unknown[] = Array.isArray(locationValue) ? locationValue : locationValue === null || locationValue === undefined ? [] : [locationValue];
|
|
644
|
+
return locations.filter(isRecord).map((location: JsonObject): JsonObject => {
|
|
645
|
+
const range: JsonObject | null = normalizeRange(location.range);
|
|
646
|
+
return {
|
|
647
|
+
uri: location.uri ?? null,
|
|
648
|
+
resourcePath: uriToResourcePath(workspace, location.uri),
|
|
649
|
+
lineStart: range?.["lineStart"] ?? null,
|
|
650
|
+
columnStart: range?.["columnStart"] ?? null,
|
|
651
|
+
lineEnd: range?.["lineEnd"] ?? null,
|
|
652
|
+
columnEnd: range?.["columnEnd"] ?? null
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function sourcePathToResourcePath(workspace: WorkspaceConfig, sourcePath: unknown): string | null {
|
|
658
|
+
if (typeof sourcePath !== "string" || sourcePath.length === 0) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (sourcePath.startsWith("res://")) {
|
|
663
|
+
return sourcePath;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const absolutePath: string = path.isAbsolute(sourcePath)
|
|
667
|
+
? path.resolve(sourcePath)
|
|
668
|
+
: path.resolve(workspace.rootPath, sourcePath);
|
|
669
|
+
const projectRoot: string = path.resolve(workspace.rootPath);
|
|
670
|
+
const relativePath: string = path.relative(projectRoot, absolutePath);
|
|
671
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
672
|
+
return sourcePath;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return `res://${relativePath.split(path.sep).join("/")}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function normalizeDapFrame(workspace: WorkspaceConfig, frame: JsonObject): JsonObject {
|
|
679
|
+
const source: unknown = frame.source;
|
|
680
|
+
const sourcePath: unknown = isRecord(source) ? source.path : undefined;
|
|
681
|
+
return {
|
|
682
|
+
id: frame.id ?? null,
|
|
683
|
+
name: clipText(frame.name ?? "", 300),
|
|
684
|
+
resourcePath: sourcePathToResourcePath(workspace, sourcePath),
|
|
685
|
+
sourcePath: typeof sourcePath === "string" ? sourcePath : null,
|
|
686
|
+
line: frame.line ?? null,
|
|
687
|
+
column: frame.column ?? null
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function normalizeDapVariable(variable: JsonObject): JsonObject {
|
|
692
|
+
return {
|
|
693
|
+
name: clipText(variable.name ?? "", 200),
|
|
694
|
+
value: clipText(variable.value ?? "", 600),
|
|
695
|
+
type: variable.type === undefined ? null : clipText(variable.type, 120),
|
|
696
|
+
variablesReference: variable.variablesReference ?? 0,
|
|
697
|
+
indexedVariables: variable.indexedVariables ?? null,
|
|
698
|
+
namedVariables: variable.namedVariables ?? null
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export class GodotDiagnosticsBridge {
|
|
703
|
+
private workspace?: WorkspaceConfig | undefined;
|
|
704
|
+
private cachedLspStatus: CachedEndpointStatus = this.createDefaultCachedStatus(DEFAULT_LSP_HOST, DEFAULT_LSP_PORT, "default");
|
|
705
|
+
private cachedDapStatus: CachedEndpointStatus = this.createDefaultCachedStatus(DEFAULT_DAP_HOST, DEFAULT_DAP_PORT, "default");
|
|
706
|
+
|
|
707
|
+
setWorkspace(workspace: WorkspaceConfig): void {
|
|
708
|
+
this.workspace = workspace;
|
|
709
|
+
this.cachedLspStatus = this.createDefaultCachedStatus(DEFAULT_LSP_HOST, DEFAULT_LSP_PORT, "default");
|
|
710
|
+
this.cachedDapStatus = this.createDefaultCachedStatus(DEFAULT_DAP_HOST, DEFAULT_DAP_PORT, "default");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
clearWorkspace(workspaceId?: string): void {
|
|
714
|
+
if (workspaceId !== undefined && this.workspace?.id !== workspaceId) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.workspace = undefined;
|
|
719
|
+
this.cachedLspStatus = this.createDefaultCachedStatus(DEFAULT_LSP_HOST, DEFAULT_LSP_PORT, "default");
|
|
720
|
+
this.cachedDapStatus = this.createDefaultCachedStatus(DEFAULT_DAP_HOST, DEFAULT_DAP_PORT, "default");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
getCachedStatus(): JsonObject {
|
|
724
|
+
return {
|
|
725
|
+
serverId: GODOT_DIAGNOSTICS_SERVER_ID,
|
|
726
|
+
workspaceId: this.workspace?.id ?? null,
|
|
727
|
+
workspaceRoot: this.workspace?.rootPath ?? null,
|
|
728
|
+
lsp: this.cachedLspStatus,
|
|
729
|
+
dap: this.cachedDapStatus
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
listTools() {
|
|
734
|
+
return {
|
|
735
|
+
tools: [
|
|
736
|
+
{
|
|
737
|
+
name: "lsp_get_status",
|
|
738
|
+
description: "探测 Godot GDScript LSP 是否可用,并返回 host/port/最近错误。",
|
|
739
|
+
inputSchema: { type: "object", properties: {}, required: [] }
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "lsp_get_file_diagnostics",
|
|
743
|
+
description: "读取指定 GDScript 文件的 LSP 诊断,返回 1-based 行列。",
|
|
744
|
+
inputSchema: {
|
|
745
|
+
type: "object",
|
|
746
|
+
properties: {
|
|
747
|
+
resourcePath: { type: "string", description: "脚本路径,可用 res://、项目相对路径或项目内绝对路径。" }
|
|
748
|
+
},
|
|
749
|
+
required: ["resourcePath"]
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
name: "lsp_get_document_symbols",
|
|
754
|
+
description: "读取指定 GDScript 文件的 document symbols 摘要。",
|
|
755
|
+
inputSchema: {
|
|
756
|
+
type: "object",
|
|
757
|
+
properties: {
|
|
758
|
+
resourcePath: { type: "string" }
|
|
759
|
+
},
|
|
760
|
+
required: ["resourcePath"]
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: "lsp_hover",
|
|
765
|
+
description: "读取指定 GDScript 文件某个 1-based 行列位置的 hover 信息。",
|
|
766
|
+
inputSchema: {
|
|
767
|
+
type: "object",
|
|
768
|
+
properties: {
|
|
769
|
+
resourcePath: { type: "string" },
|
|
770
|
+
line: { type: "integer" },
|
|
771
|
+
column: { type: "integer" }
|
|
772
|
+
},
|
|
773
|
+
required: ["resourcePath", "line", "column"]
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: "lsp_goto_definition",
|
|
778
|
+
description: "读取指定 GDScript 文件某个 1-based 行列位置的 definition 位置。",
|
|
779
|
+
inputSchema: {
|
|
780
|
+
type: "object",
|
|
781
|
+
properties: {
|
|
782
|
+
resourcePath: { type: "string" },
|
|
783
|
+
line: { type: "integer" },
|
|
784
|
+
column: { type: "integer" }
|
|
785
|
+
},
|
|
786
|
+
required: ["resourcePath", "line", "column"]
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
name: "dap_get_status",
|
|
791
|
+
description: "探测 Godot DAP 是否可用,并只读检查是否可 attach 到当前运行会话。",
|
|
792
|
+
inputSchema: { type: "object", properties: {}, required: [] }
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: "dap_get_last_error",
|
|
796
|
+
description: "只读读取当前 DAP stopped/output 事件和顶部调用栈摘要;不控制调试器。",
|
|
797
|
+
inputSchema: { type: "object", properties: {}, required: [] }
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
name: "dap_get_stack_trace",
|
|
801
|
+
description: "只读读取当前运行会话调用栈和 frame scopes。",
|
|
802
|
+
inputSchema: { type: "object", properties: {}, required: [] }
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: "dap_get_variables",
|
|
806
|
+
description: "只读读取 DAP variablesReference 对应变量摘要。",
|
|
807
|
+
inputSchema: {
|
|
808
|
+
type: "object",
|
|
809
|
+
properties: {
|
|
810
|
+
variablesReference: { type: "integer", description: "来自 dap_get_stack_trace scopes 或变量结果的 variablesReference。" }
|
|
811
|
+
},
|
|
812
|
+
required: ["variablesReference"]
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
]
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
listResources() {
|
|
820
|
+
return {
|
|
821
|
+
resources: [
|
|
822
|
+
{
|
|
823
|
+
uri: "godot-diagnostics://status",
|
|
824
|
+
name: "Godot Diagnostics Status",
|
|
825
|
+
mimeType: "application/json"
|
|
826
|
+
}
|
|
827
|
+
]
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
readResource(uri: string) {
|
|
832
|
+
if (uri !== "godot-diagnostics://status") {
|
|
833
|
+
throw new Error(`Unknown godot_diagnostics resource: ${uri}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
contents: [
|
|
838
|
+
{
|
|
839
|
+
uri,
|
|
840
|
+
mimeType: "application/json",
|
|
841
|
+
text: JSON.stringify(this.getCachedStatus(), null, 2)
|
|
842
|
+
}
|
|
843
|
+
]
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async callTool(name: string, args: JsonObject): Promise<ToolTextResult> {
|
|
848
|
+
try {
|
|
849
|
+
switch (name) {
|
|
850
|
+
case "lsp_get_status":
|
|
851
|
+
return jsonTextResult(await this.getLspStatus());
|
|
852
|
+
case "lsp_get_file_diagnostics":
|
|
853
|
+
return jsonTextResult(await this.getLspFileDiagnostics(this.getStringArg(args, "resourcePath")));
|
|
854
|
+
case "lsp_get_document_symbols":
|
|
855
|
+
return jsonTextResult(await this.getLspDocumentSymbols(this.getStringArg(args, "resourcePath")));
|
|
856
|
+
case "lsp_hover":
|
|
857
|
+
return jsonTextResult(await this.getLspHover(this.getStringArg(args, "resourcePath"), this.getIntegerArg(args, "line"), this.getIntegerArg(args, "column")));
|
|
858
|
+
case "lsp_goto_definition":
|
|
859
|
+
return jsonTextResult(await this.getLspDefinition(this.getStringArg(args, "resourcePath"), this.getIntegerArg(args, "line"), this.getIntegerArg(args, "column")));
|
|
860
|
+
case "dap_get_status":
|
|
861
|
+
return jsonTextResult(await this.getDapStatus());
|
|
862
|
+
case "dap_get_last_error":
|
|
863
|
+
return jsonTextResult(await this.getDapLastError());
|
|
864
|
+
case "dap_get_stack_trace":
|
|
865
|
+
return jsonTextResult(await this.getDapStackTrace());
|
|
866
|
+
case "dap_get_variables":
|
|
867
|
+
return jsonTextResult(await this.getDapVariables(this.getIntegerArg(args, "variablesReference")));
|
|
868
|
+
default:
|
|
869
|
+
throw new Error(`Unknown godot_diagnostics tool: ${name}`);
|
|
870
|
+
}
|
|
871
|
+
} catch (error: unknown) {
|
|
872
|
+
return jsonTextResult({
|
|
873
|
+
ok: false,
|
|
874
|
+
error: {
|
|
875
|
+
code: "godot_diagnostics_error",
|
|
876
|
+
message: error instanceof Error ? error.message : "Godot diagnostics tool failed"
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async getLspStatus(): Promise<JsonObject> {
|
|
883
|
+
const { config, peer } = await this.connectLsp();
|
|
884
|
+
peer.close();
|
|
885
|
+
this.markStatus("lsp", config.lsp, true, null);
|
|
886
|
+
return {
|
|
887
|
+
ok: true,
|
|
888
|
+
available: true,
|
|
889
|
+
endpoint: config.lsp,
|
|
890
|
+
editorSettingsFile: config.editorSettingsFile
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private async getLspFileDiagnostics(resourcePathInput: string): Promise<JsonObject> {
|
|
895
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
896
|
+
const resolvedPath: ResolvedResourcePath = ensureWorkspaceResourcePath(workspace, resourcePathInput);
|
|
897
|
+
const { config, peer } = await this.connectLsp();
|
|
898
|
+
|
|
899
|
+
try {
|
|
900
|
+
const diagnostics: JsonObject[] = await this.withOpenLspDocument(peer, resolvedPath, async (): Promise<JsonObject[]> => {
|
|
901
|
+
const notification: JsonObject | null = await peer.waitForNotification((message: JsonObject): boolean => (
|
|
902
|
+
message.method === "textDocument/publishDiagnostics"
|
|
903
|
+
&& isRecord(message.params)
|
|
904
|
+
&& message.params["uri"] === resolvedPath.uri
|
|
905
|
+
), DIAGNOSTICS_WAIT_MS);
|
|
906
|
+
|
|
907
|
+
if (notification === null || !isRecord(notification.params)) {
|
|
908
|
+
return [];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return normalizeDiagnostics(resolvedPath.resourcePath, notification.params["diagnostics"]);
|
|
912
|
+
});
|
|
913
|
+
this.markStatus("lsp", config.lsp, true, null);
|
|
914
|
+
return {
|
|
915
|
+
ok: true,
|
|
916
|
+
resourcePath: resolvedPath.resourcePath,
|
|
917
|
+
diagnostics,
|
|
918
|
+
truncated: diagnostics.length >= MAX_DIAGNOSTICS
|
|
919
|
+
};
|
|
920
|
+
} finally {
|
|
921
|
+
peer.close();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async getLspDocumentSymbols(resourcePathInput: string): Promise<JsonObject> {
|
|
926
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
927
|
+
const resolvedPath: ResolvedResourcePath = ensureWorkspaceResourcePath(workspace, resourcePathInput);
|
|
928
|
+
const { config, peer } = await this.connectLsp();
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
const symbols: JsonObject[] = await this.withOpenLspDocument(peer, resolvedPath, async (): Promise<JsonObject[]> => {
|
|
932
|
+
const result: unknown = await peer.request("textDocument/documentSymbol", {
|
|
933
|
+
textDocument: { uri: resolvedPath.uri }
|
|
934
|
+
});
|
|
935
|
+
return normalizeSymbols(result);
|
|
936
|
+
});
|
|
937
|
+
this.markStatus("lsp", config.lsp, true, null);
|
|
938
|
+
return {
|
|
939
|
+
ok: true,
|
|
940
|
+
resourcePath: resolvedPath.resourcePath,
|
|
941
|
+
symbols,
|
|
942
|
+
truncated: symbols.length >= MAX_SYMBOLS
|
|
943
|
+
};
|
|
944
|
+
} finally {
|
|
945
|
+
peer.close();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private async getLspHover(resourcePathInput: string, line: number, column: number): Promise<JsonObject> {
|
|
950
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
951
|
+
const resolvedPath: ResolvedResourcePath = ensureWorkspaceResourcePath(workspace, resourcePathInput);
|
|
952
|
+
const { config, peer } = await this.connectLsp();
|
|
953
|
+
|
|
954
|
+
try {
|
|
955
|
+
const hover: unknown = await this.withOpenLspDocument(peer, resolvedPath, async (): Promise<unknown> => await peer.request("textDocument/hover", {
|
|
956
|
+
textDocument: { uri: resolvedPath.uri },
|
|
957
|
+
position: {
|
|
958
|
+
line: Math.max(0, line - 1),
|
|
959
|
+
character: Math.max(0, column - 1)
|
|
960
|
+
}
|
|
961
|
+
}));
|
|
962
|
+
this.markStatus("lsp", config.lsp, true, null);
|
|
963
|
+
return {
|
|
964
|
+
ok: true,
|
|
965
|
+
resourcePath: resolvedPath.resourcePath,
|
|
966
|
+
line,
|
|
967
|
+
column,
|
|
968
|
+
hover: hover === null ? null : clipText(hover, 3000)
|
|
969
|
+
};
|
|
970
|
+
} finally {
|
|
971
|
+
peer.close();
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
private async getLspDefinition(resourcePathInput: string, line: number, column: number): Promise<JsonObject> {
|
|
976
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
977
|
+
const resolvedPath: ResolvedResourcePath = ensureWorkspaceResourcePath(workspace, resourcePathInput);
|
|
978
|
+
const { config, peer } = await this.connectLsp();
|
|
979
|
+
|
|
980
|
+
try {
|
|
981
|
+
const definition: unknown = await this.withOpenLspDocument(peer, resolvedPath, async (): Promise<unknown> => await peer.request("textDocument/definition", {
|
|
982
|
+
textDocument: { uri: resolvedPath.uri },
|
|
983
|
+
position: {
|
|
984
|
+
line: Math.max(0, line - 1),
|
|
985
|
+
character: Math.max(0, column - 1)
|
|
986
|
+
}
|
|
987
|
+
}));
|
|
988
|
+
this.markStatus("lsp", config.lsp, true, null);
|
|
989
|
+
return {
|
|
990
|
+
ok: true,
|
|
991
|
+
resourcePath: resolvedPath.resourcePath,
|
|
992
|
+
line,
|
|
993
|
+
column,
|
|
994
|
+
locations: normalizeLocations(workspace, definition)
|
|
995
|
+
};
|
|
996
|
+
} finally {
|
|
997
|
+
peer.close();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private async getDapStatus(): Promise<JsonObject> {
|
|
1002
|
+
const { config, peer } = await this.connectDap();
|
|
1003
|
+
try {
|
|
1004
|
+
const attachResponse: DapResponse = await peer.request("attach", { project: this.requireWorkspace().rootPath });
|
|
1005
|
+
const running: boolean = attachResponse.success;
|
|
1006
|
+
this.markStatus("dap", config.dap, true, running ? null : attachResponse.message ?? "not_running");
|
|
1007
|
+
return {
|
|
1008
|
+
ok: true,
|
|
1009
|
+
available: true,
|
|
1010
|
+
running,
|
|
1011
|
+
endpoint: config.dap,
|
|
1012
|
+
attach: this.summarizeDapResponse(attachResponse),
|
|
1013
|
+
editorSettingsFile: config.editorSettingsFile
|
|
1014
|
+
};
|
|
1015
|
+
} finally {
|
|
1016
|
+
peer.close();
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private async getDapLastError(): Promise<JsonObject> {
|
|
1021
|
+
const stackResult: JsonObject = await this.getDapStackTrace();
|
|
1022
|
+
if (stackResult.ok !== true) {
|
|
1023
|
+
return stackResult;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const events: unknown = stackResult["events"];
|
|
1027
|
+
const stoppedEvents: JsonObject[] = Array.isArray(events)
|
|
1028
|
+
? events.filter(isRecord).filter((event: JsonObject): boolean => event.event === "stopped")
|
|
1029
|
+
: [];
|
|
1030
|
+
return {
|
|
1031
|
+
ok: true,
|
|
1032
|
+
running: stackResult["running"],
|
|
1033
|
+
lastStoppedEvent: stoppedEvents.at(-1) ?? null,
|
|
1034
|
+
topFrame: Array.isArray(stackResult["frames"]) ? stackResult["frames"][0] ?? null : null,
|
|
1035
|
+
frames: stackResult["frames"] ?? [],
|
|
1036
|
+
events: stackResult["events"] ?? []
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private async getDapStackTrace(): Promise<JsonObject> {
|
|
1041
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
1042
|
+
const { config, peer } = await this.connectDap();
|
|
1043
|
+
try {
|
|
1044
|
+
const attachResponse: DapResponse = await peer.request("attach", { project: workspace.rootPath });
|
|
1045
|
+
if (!attachResponse.success) {
|
|
1046
|
+
this.markStatus("dap", config.dap, true, attachResponse.message ?? "not_running");
|
|
1047
|
+
return {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
running: false,
|
|
1050
|
+
endpoint: config.dap,
|
|
1051
|
+
attach: this.summarizeDapResponse(attachResponse),
|
|
1052
|
+
frames: [],
|
|
1053
|
+
events: peer.getEvents()
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
await peer.waitForEvents(DAP_EVENT_WAIT_MS);
|
|
1058
|
+
const threadsResponse: DapResponse = await peer.request("threads", {});
|
|
1059
|
+
const threads: JsonObject[] = this.extractDapArray(threadsResponse.body, "threads");
|
|
1060
|
+
const threadId: unknown = threads[0]?.["id"] ?? 1;
|
|
1061
|
+
const stackResponse: DapResponse = await peer.request("stackTrace", {
|
|
1062
|
+
threadId,
|
|
1063
|
+
startFrame: 0,
|
|
1064
|
+
levels: MAX_STACK_FRAMES
|
|
1065
|
+
});
|
|
1066
|
+
const rawFrames: JsonObject[] = this.extractDapArray(stackResponse.body, "stackFrames");
|
|
1067
|
+
const frames: JsonObject[] = [];
|
|
1068
|
+
for (const rawFrame of rawFrames.slice(0, MAX_STACK_FRAMES)) {
|
|
1069
|
+
const frame: JsonObject = normalizeDapFrame(workspace, rawFrame);
|
|
1070
|
+
if (typeof rawFrame.id === "number") {
|
|
1071
|
+
const scopesResponse: DapResponse = await peer.request("scopes", { frameId: rawFrame.id }).catch((error: unknown): DapResponse => ({
|
|
1072
|
+
type: "response",
|
|
1073
|
+
request_seq: -1,
|
|
1074
|
+
command: "scopes",
|
|
1075
|
+
success: false,
|
|
1076
|
+
message: error instanceof Error ? error.message : "scopes failed"
|
|
1077
|
+
}));
|
|
1078
|
+
frame["scopes"] = scopesResponse.success ? this.extractDapArray(scopesResponse.body, "scopes") : [];
|
|
1079
|
+
} else {
|
|
1080
|
+
frame["scopes"] = [];
|
|
1081
|
+
}
|
|
1082
|
+
frames.push(frame);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
this.markStatus("dap", config.dap, true, null);
|
|
1086
|
+
return {
|
|
1087
|
+
ok: true,
|
|
1088
|
+
running: true,
|
|
1089
|
+
endpoint: config.dap,
|
|
1090
|
+
threads,
|
|
1091
|
+
frames,
|
|
1092
|
+
events: peer.getEvents()
|
|
1093
|
+
};
|
|
1094
|
+
} finally {
|
|
1095
|
+
peer.close();
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private async getDapVariables(variablesReference: number): Promise<JsonObject> {
|
|
1100
|
+
if (!Number.isInteger(variablesReference) || variablesReference <= 0) {
|
|
1101
|
+
throw new Error("variablesReference must be a positive integer");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const { config, peer } = await this.connectDap();
|
|
1105
|
+
try {
|
|
1106
|
+
const attachResponse: DapResponse = await peer.request("attach", { project: this.requireWorkspace().rootPath });
|
|
1107
|
+
if (!attachResponse.success) {
|
|
1108
|
+
this.markStatus("dap", config.dap, true, attachResponse.message ?? "not_running");
|
|
1109
|
+
return {
|
|
1110
|
+
ok: true,
|
|
1111
|
+
running: false,
|
|
1112
|
+
endpoint: config.dap,
|
|
1113
|
+
variablesReference,
|
|
1114
|
+
variables: [],
|
|
1115
|
+
attach: this.summarizeDapResponse(attachResponse)
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const variablesResponse: DapResponse = await peer.request("variables", { variablesReference });
|
|
1120
|
+
if (!variablesResponse.success) {
|
|
1121
|
+
return {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
error: {
|
|
1124
|
+
code: "dap_variables_failed",
|
|
1125
|
+
message: variablesResponse.message ?? "variables request failed"
|
|
1126
|
+
},
|
|
1127
|
+
variablesReference
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const variables: JsonObject[] = this.extractDapArray(variablesResponse.body, "variables")
|
|
1132
|
+
.slice(0, MAX_VARIABLES)
|
|
1133
|
+
.map(normalizeDapVariable);
|
|
1134
|
+
this.markStatus("dap", config.dap, true, null);
|
|
1135
|
+
return {
|
|
1136
|
+
ok: true,
|
|
1137
|
+
running: true,
|
|
1138
|
+
endpoint: config.dap,
|
|
1139
|
+
variablesReference,
|
|
1140
|
+
variables,
|
|
1141
|
+
truncated: variables.length >= MAX_VARIABLES
|
|
1142
|
+
};
|
|
1143
|
+
} finally {
|
|
1144
|
+
peer.close();
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
private async connectLsp(): Promise<{ config: DiagnosticsConfig; peer: JsonRpcPeer }> {
|
|
1149
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
1150
|
+
const config: DiagnosticsConfig = await resolveDiagnosticsConfig(workspace);
|
|
1151
|
+
try {
|
|
1152
|
+
const socket: net.Socket = await connectTcp(config.lsp);
|
|
1153
|
+
const peer: JsonRpcPeer = new JsonRpcPeer(socket);
|
|
1154
|
+
await peer.request("initialize", {
|
|
1155
|
+
processId: process.pid,
|
|
1156
|
+
rootPath: workspace.rootPath,
|
|
1157
|
+
rootUri: pathToFileURL(path.resolve(workspace.rootPath)).href,
|
|
1158
|
+
capabilities: {
|
|
1159
|
+
textDocument: {
|
|
1160
|
+
publishDiagnostics: {
|
|
1161
|
+
relatedInformation: true
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
},
|
|
1165
|
+
workspaceFolders: [
|
|
1166
|
+
{
|
|
1167
|
+
uri: pathToFileURL(path.resolve(workspace.rootPath)).href,
|
|
1168
|
+
name: path.basename(workspace.rootPath)
|
|
1169
|
+
}
|
|
1170
|
+
]
|
|
1171
|
+
});
|
|
1172
|
+
peer.notify("initialized", {});
|
|
1173
|
+
return { config, peer };
|
|
1174
|
+
} catch (error: unknown) {
|
|
1175
|
+
this.markStatus("lsp", config.lsp, false, error instanceof Error ? error.message : "lsp_unavailable");
|
|
1176
|
+
throw new Error(`lsp_unavailable: ${error instanceof Error ? error.message : "Godot LSP is not available"}`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private async connectDap(): Promise<{ config: DiagnosticsConfig; peer: DapPeer }> {
|
|
1181
|
+
const workspace: WorkspaceConfig = this.requireWorkspace();
|
|
1182
|
+
const config: DiagnosticsConfig = await resolveDiagnosticsConfig(workspace);
|
|
1183
|
+
try {
|
|
1184
|
+
const socket: net.Socket = await connectTcp(config.dap);
|
|
1185
|
+
const peer: DapPeer = new DapPeer(socket);
|
|
1186
|
+
const initializeResponse: DapResponse = await peer.request("initialize", {
|
|
1187
|
+
adapterID: "godot",
|
|
1188
|
+
clientID: "godot-daedalus",
|
|
1189
|
+
clientName: "Godot Daedalus",
|
|
1190
|
+
linesStartAt1: true,
|
|
1191
|
+
columnsStartAt1: true,
|
|
1192
|
+
pathFormat: "path",
|
|
1193
|
+
supportsVariableType: true,
|
|
1194
|
+
supportsInvalidatedEvent: true,
|
|
1195
|
+
supportsRunInTerminalRequest: false
|
|
1196
|
+
});
|
|
1197
|
+
if (!initializeResponse.success) {
|
|
1198
|
+
throw new Error(initializeResponse.message ?? "DAP initialize failed");
|
|
1199
|
+
}
|
|
1200
|
+
return { config, peer };
|
|
1201
|
+
} catch (error: unknown) {
|
|
1202
|
+
this.markStatus("dap", config.dap, false, error instanceof Error ? error.message : "dap_unavailable");
|
|
1203
|
+
throw new Error(`dap_unavailable: ${error instanceof Error ? error.message : "Godot DAP is not available"}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
private async withOpenLspDocument<T>(peer: JsonRpcPeer, resolvedPath: ResolvedResourcePath, callback: () => Promise<T>): Promise<T> {
|
|
1208
|
+
const text: string = await fs.readFile(resolvedPath.absolutePath, "utf8");
|
|
1209
|
+
peer.notify("textDocument/didOpen", {
|
|
1210
|
+
textDocument: {
|
|
1211
|
+
uri: resolvedPath.uri,
|
|
1212
|
+
languageId: "gdscript",
|
|
1213
|
+
version: 1,
|
|
1214
|
+
text
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
return await callback();
|
|
1220
|
+
} finally {
|
|
1221
|
+
peer.notify("textDocument/didClose", {
|
|
1222
|
+
textDocument: {
|
|
1223
|
+
uri: resolvedPath.uri
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private extractDapArray(body: unknown, key: string): JsonObject[] {
|
|
1230
|
+
if (!isRecord(body) || !Array.isArray(body[key])) {
|
|
1231
|
+
return [];
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return body[key].filter(isRecord);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private summarizeDapResponse(response: DapResponse): JsonObject {
|
|
1238
|
+
return {
|
|
1239
|
+
success: response.success,
|
|
1240
|
+
command: response.command,
|
|
1241
|
+
message: response.message ?? null,
|
|
1242
|
+
error: isRecord(response.body) ? response.body["error"] ?? null : null
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private markStatus(kind: "lsp" | "dap", endpoint: Endpoint, available: boolean, error: string | null): void {
|
|
1247
|
+
const status: CachedEndpointStatus = {
|
|
1248
|
+
host: endpoint.host,
|
|
1249
|
+
port: endpoint.port,
|
|
1250
|
+
source: endpoint.source,
|
|
1251
|
+
available,
|
|
1252
|
+
lastCheckedAt: new Date().toISOString(),
|
|
1253
|
+
lastError: error
|
|
1254
|
+
};
|
|
1255
|
+
if (kind === "lsp") {
|
|
1256
|
+
this.cachedLspStatus = status;
|
|
1257
|
+
} else {
|
|
1258
|
+
this.cachedDapStatus = status;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private createDefaultCachedStatus(host: string, port: number, source: string): CachedEndpointStatus {
|
|
1263
|
+
return {
|
|
1264
|
+
host,
|
|
1265
|
+
port,
|
|
1266
|
+
source,
|
|
1267
|
+
available: null,
|
|
1268
|
+
lastCheckedAt: null,
|
|
1269
|
+
lastError: null
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private requireWorkspace(): WorkspaceConfig {
|
|
1274
|
+
if (this.workspace === undefined) {
|
|
1275
|
+
throw new Error("godot_diagnostics_unavailable: no active workspace");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return this.workspace;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
private getStringArg(args: JsonObject, key: string): string {
|
|
1282
|
+
const value: unknown = args[key];
|
|
1283
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1284
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return value;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private getIntegerArg(args: JsonObject, key: string): number {
|
|
1291
|
+
const value: unknown = args[key];
|
|
1292
|
+
if (!Number.isInteger(value)) {
|
|
1293
|
+
throw new Error(`${key} must be an integer`);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return Number(value);
|
|
1297
|
+
}
|
|
1298
|
+
}
|