|
|
@@ -0,0 +1,672 @@
|
|
|
+/** DAP debug adapter for Pascal programs, driving `gdb --interpreter=mi`.
|
|
|
+ *
|
|
|
+ * Plain Node.js, no Theia (or other) dependencies: Theia spawns this file
|
|
|
+ * as the `pascal` debug adapter executable and talks DAP over stdio.
|
|
|
+ *
|
|
|
+ * Supported DAP surface (deliberately small, all verified live):
|
|
|
+ * initialize, launch, setBreakpoints (+ conditions), configurationDone,
|
|
|
+ * threads, stackTrace, scopes (Locals), variables (with structure
|
|
|
+ * expansion), evaluate, continue, next, stepIn, stepOut, pause, disconnect.
|
|
|
+ * Everything else answers "unsupported" instead of hanging.
|
|
|
+ */
|
|
|
+
|
|
|
+import { spawn, ChildProcess, spawnSync } from 'child_process';
|
|
|
+import * as fs from 'fs';
|
|
|
+import * as path from 'path';
|
|
|
+import {
|
|
|
+ MIRecord, MIValue, asFields, asList, asString, classifyLine, fieldString, isRecord,
|
|
|
+} from './mi';
|
|
|
+
|
|
|
+/** Module-level variables from DWARF (frame queries only see procedure locals).
|
|
|
+ *
|
|
|
+ * gm2 emits module `VAR`s as top-level `DW_TAG_variable` DIEs. A single
|
|
|
+ * `readelf` scan per program collects them for compilation units backed by
|
|
|
+ * `.mod` files. Best effort: any failure yields an empty list and the
|
|
|
+ * Module scope is simply omitted (hover/evaluate still resolve every name).
|
|
|
+ */
|
|
|
+export function readModuleGlobals(program: string): string[] {
|
|
|
+ try {
|
|
|
+ const out = spawnSync('readelf', ['--debug-dump=info', program], {
|
|
|
+ encoding: 'utf8',
|
|
|
+ env: { ...process.env, LC_ALL: 'C' },
|
|
|
+ timeout: 15000,
|
|
|
+ });
|
|
|
+ if (out.status !== 0 || !out.stdout) return [];
|
|
|
+ return scanVariables(out.stdout);
|
|
|
+ } catch {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Second pass over the dump: collect depth-1 variables of .mod units. */
|
|
|
+function scanVariables(dump: string): string[] {
|
|
|
+ const names: string[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+ let inModUnit = false;
|
|
|
+ let expectVarName = false;
|
|
|
+ for (const line of dump.split('\n')) {
|
|
|
+ const die = /^ <(\d+)><[^>]+>:\s*Abbrev Number:\s*\d+\s*\((DW_TAG_\w+)\)/.exec(line);
|
|
|
+ if (die) {
|
|
|
+ const [, depth, tag] = die;
|
|
|
+ if (tag === 'DW_TAG_compile_unit' || tag === 'DW_TAG_partial_unit') {
|
|
|
+ inModUnit = false;
|
|
|
+ expectVarName = false;
|
|
|
+ } else if (tag === 'DW_TAG_variable' && depth === '1' && inModUnit) {
|
|
|
+ expectVarName = true;
|
|
|
+ } else {
|
|
|
+ expectVarName = false;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const nm = /DW_AT_name\s*:\s*(?:\([^)]*\)\s*:\s*)?(\S+)\s*$/.exec(line);
|
|
|
+ if (!nm) continue;
|
|
|
+ const name = nm[1];
|
|
|
+ if (expectVarName) {
|
|
|
+ expectVarName = false;
|
|
|
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !seen.has(name)) {
|
|
|
+ seen.add(name);
|
|
|
+ names.push(name);
|
|
|
+ }
|
|
|
+ } else if (name.endsWith('.pas') || name.endsWith('.pp')) {
|
|
|
+ // A CU name: the next depth-1 variables belong to this unit.
|
|
|
+ // (CU DIE precedes its children; runtime units never end with .pas.)
|
|
|
+ inModUnit = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return names;
|
|
|
+}
|
|
|
+
|
|
|
+interface DapMessage {
|
|
|
+ seq: number;
|
|
|
+ type: string;
|
|
|
+ command?: string;
|
|
|
+ arguments?: Record<string, unknown>;
|
|
|
+ request_seq?: number;
|
|
|
+ success?: boolean;
|
|
|
+ message?: string;
|
|
|
+ body?: unknown;
|
|
|
+ event?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface Breakpoint {
|
|
|
+ line: number;
|
|
|
+ number: number;
|
|
|
+ condition?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface FrameRef {
|
|
|
+ threadId: number;
|
|
|
+ level: number;
|
|
|
+}
|
|
|
+
|
|
|
+type VarHandle = {
|
|
|
+ kind: 'locals';
|
|
|
+ threadId: number;
|
|
|
+ level: number;
|
|
|
+ frameId: number;
|
|
|
+} | {
|
|
|
+ kind: 'varobj';
|
|
|
+ name: string;
|
|
|
+} | {
|
|
|
+ kind: 'module';
|
|
|
+};
|
|
|
+
|
|
|
+const SCALAR_TYPES = new Set([
|
|
|
+ 'INTEGER', 'CARDINAL', 'LONGINT', 'SHORTINT', 'LONGCARD', 'SHORTCARD',
|
|
|
+ 'SMALLINT', 'INT64', 'QWORD', 'DWORD', 'NATIVEINT', 'NATIVESIZE',
|
|
|
+ 'REAL', 'LONGREAL', 'SINGLE', 'DOUBLE', 'EXTENDED', 'CURRENCY',
|
|
|
+ 'CHAR', 'ANSICHAR', 'WIDECHAR', 'BOOLEAN', 'BYTEBOOL', 'WORDBOOL', 'LONGBOOL',
|
|
|
+ 'BITSET', 'WORD', 'BYTE', 'SHORTSTRING', 'STRING',
|
|
|
+]);
|
|
|
+
|
|
|
+class Adapter {
|
|
|
+ private seq = 0;
|
|
|
+ private buffer = '';
|
|
|
+ private gdb: ChildProcess | null = null;
|
|
|
+ private gdbBuffer = '';
|
|
|
+ private token = 0;
|
|
|
+ private pending = new Map<string, (rec: MIRecord) => void>();
|
|
|
+ private running = false;
|
|
|
+ private started = false;
|
|
|
+ private exited = false;
|
|
|
+ private program = '';
|
|
|
+ private moduleVars: string[] = [];
|
|
|
+ private entryBp: number | null = null;
|
|
|
+ private breakpoints = new Map<string, Breakpoint[]>();
|
|
|
+ private threadsCache: Array<{ id: number; name: string }> = [{ id: 1, name: 'main' }];
|
|
|
+ private frames = new Map<number, FrameRef>();
|
|
|
+ private handles = new Map<number, VarHandle>();
|
|
|
+ private nextHandle = 1000;
|
|
|
+ private varObjs = new Set<string>();
|
|
|
+ private varSeq = 0;
|
|
|
+
|
|
|
+ run(): void {
|
|
|
+ process.stdin.on('data', d => this.onDapData(d.toString('utf8')));
|
|
|
+ process.stdin.on('end', () => this.shutdown());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- DAP framing ----
|
|
|
+
|
|
|
+ private onDapData(chunk: string): void {
|
|
|
+ this.buffer += chunk;
|
|
|
+ for (;;) {
|
|
|
+ const head = /Content-Length:\s*(\d+)\r\n\r\n/.exec(this.buffer);
|
|
|
+ if (!head) return;
|
|
|
+ const n = Number(head[1]);
|
|
|
+ const start = head.index + head[0].length;
|
|
|
+ if (this.buffer.length < start + n) return;
|
|
|
+ const body = this.buffer.slice(start, start + n);
|
|
|
+ this.buffer = this.buffer.slice(start + n);
|
|
|
+ try {
|
|
|
+ this.dispatch(JSON.parse(body) as DapMessage);
|
|
|
+ } catch {
|
|
|
+ // Ignore malformed input.
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private send(msg: object): void {
|
|
|
+ const body = JSON.stringify(msg);
|
|
|
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ private respond(req: DapMessage, body?: unknown, success = true, message?: string): void {
|
|
|
+ this.send({
|
|
|
+ seq: ++this.seq, type: 'response', request_seq: req.seq,
|
|
|
+ success, command: req.command, message, body: body ?? {},
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private event(event: string, body?: unknown): void {
|
|
|
+ this.send({ seq: ++this.seq, type: 'event', event, body: body ?? {} });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- GDB/MI transport ----
|
|
|
+
|
|
|
+ private mi(command: string): Promise<MIRecord> {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ if (!this.gdb?.stdin) {
|
|
|
+ resolve({ token: '', marker: '^', cls: 'error', rest: '', fields: { msg: 'gdb is not running' } });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.token++;
|
|
|
+ const tok = String(this.token);
|
|
|
+ this.pending.set(tok, resolve);
|
|
|
+ this.gdb.stdin.write(`${tok}${command}\n`);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private miOk(rec: MIRecord): boolean {
|
|
|
+ return rec.marker === '^' && rec.cls === 'done';
|
|
|
+ }
|
|
|
+
|
|
|
+ private miError(rec: MIRecord): string {
|
|
|
+ return fieldString(rec.fields, 'msg') ?? 'gdb command failed';
|
|
|
+ }
|
|
|
+
|
|
|
+ private onGdbData(chunk: string): void {
|
|
|
+ this.gdbBuffer += chunk;
|
|
|
+ const lines = this.gdbBuffer.split('\n');
|
|
|
+ this.gdbBuffer = lines.pop() ?? '';
|
|
|
+ for (const line of lines) this.onGdbLine(line);
|
|
|
+ }
|
|
|
+
|
|
|
+ private onGdbLine(line: string): void {
|
|
|
+ if (!line) return;
|
|
|
+ const parsed = classifyLine(line);
|
|
|
+ if (!isRecord(parsed)) {
|
|
|
+ if ('prompt' in parsed) return;
|
|
|
+ this.event('output', { category: 'stdout', output: (parsed as { output: string }).output + '\n' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (parsed.marker === '~' || parsed.marker === '&') return; // gdb chatter
|
|
|
+ if (parsed.marker === '@') {
|
|
|
+ this.event('output', { category: 'stdout', output: fieldString(parsed.fields, 'text') ?? '' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (parsed.marker === '^' || parsed.marker === '*') {
|
|
|
+ if (parsed.token && this.pending.has(parsed.token)) {
|
|
|
+ const resolve = this.pending.get(parsed.token)!;
|
|
|
+ this.pending.delete(parsed.token);
|
|
|
+ resolve(parsed);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (parsed.marker === '*') this.onExecAsync(parsed);
|
|
|
+ }
|
|
|
+
|
|
|
+ private onExecAsync(rec: MIRecord): void {
|
|
|
+ if (rec.cls !== 'stopped') return;
|
|
|
+ this.running = false;
|
|
|
+ const reason = fieldString(rec.fields, 'reason') ?? '';
|
|
|
+ const threadId = Number(fieldString(rec.fields, 'thread-id') ?? '1') || 1;
|
|
|
+ if (reason === 'exited-normally' || reason === 'exited' || reason === 'exited-signalled') {
|
|
|
+ this.event('exited', { exitCode: reason === 'exited-normally' ? 0 : 1 });
|
|
|
+ this.event('terminated', { restart: false });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Breakpoints requested before the temporary entry breakpoint resolves first.
|
|
|
+ const bkptno = Number(fieldString(rec.fields, 'bkptno') ?? '0');
|
|
|
+ if (this.entryBp !== null && bkptno === this.entryBp) {
|
|
|
+ void this.mi(`-break-delete ${this.entryBp}`);
|
|
|
+ this.entryBp = null;
|
|
|
+ this.stoppedEvent(threadId, 'entry', rec);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const map: { [k: string]: string } = {
|
|
|
+ 'breakpoint-hit': 'breakpoint',
|
|
|
+ 'end-stepping-range': 'step',
|
|
|
+ 'function-finished': 'step',
|
|
|
+ 'signal-received': 'exception',
|
|
|
+ };
|
|
|
+ this.stoppedEvent(threadId, map[reason] ?? 'pause', rec);
|
|
|
+ }
|
|
|
+
|
|
|
+ private stoppedEvent(threadId: number, reason: string, rec: MIRecord): void {
|
|
|
+ // Drop cached varobjs: frames may have changed.
|
|
|
+ for (const name of this.varObjs) void this.mi(`-var-delete ${name}`);
|
|
|
+ this.varObjs.clear();
|
|
|
+ const frame = asFields(rec.fields['frame']);
|
|
|
+ const file = frame ? fieldString(frame, 'fullname') ?? fieldString(frame, 'file') : undefined;
|
|
|
+ const line = frame ? Number(fieldString(frame, 'line') ?? '0') : 0;
|
|
|
+ this.refreshThreads();
|
|
|
+ if (file && line > 0) {
|
|
|
+ this.event('stopped', {
|
|
|
+ reason, threadId, allThreadsStopped: true,
|
|
|
+ source: { name: path.basename(file), path: file }, line,
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ this.event('stopped', { reason, threadId, allThreadsStopped: true });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private refreshThreads(): void {
|
|
|
+ if (this.running) return;
|
|
|
+ void this.mi('-thread-info').then(rec => {
|
|
|
+ if (!this.miOk(rec)) return;
|
|
|
+ const out = this.parseThreads(rec);
|
|
|
+ if (out.length > 0) this.threadsCache = out;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- DAP dispatch ----
|
|
|
+
|
|
|
+ private dispatch(req: DapMessage): void {
|
|
|
+ if (req.type !== 'request') return;
|
|
|
+ const a = (req.arguments ?? {}) as Record<string, any>;
|
|
|
+ switch (req.command) {
|
|
|
+ case 'initialize':
|
|
|
+ this.respond(req, {
|
|
|
+ supportsConfigurationDoneRequest: true,
|
|
|
+ supportsConditionalBreakpoints: true,
|
|
|
+ supportsEvaluateForHovers: true,
|
|
|
+ });
|
|
|
+ this.event('initialized');
|
|
|
+ return;
|
|
|
+ case 'launch': void this.onLaunch(req, a); return;
|
|
|
+ case 'setBreakpoints': void this.onSetBreakpoints(req, a); return;
|
|
|
+ case 'setExceptionBreakpoints':
|
|
|
+ // No exception breakpoints in Pascal: acknowledge with an empty set
|
|
|
+ // so Theia doesn't log a spurious error on every session start.
|
|
|
+ this.respond(req, { breakpoints: [] });
|
|
|
+ return;
|
|
|
+ case 'configurationDone': void this.onConfigurationDone(req); return;
|
|
|
+ case 'threads': void this.onThreads(req); return;
|
|
|
+ case 'stackTrace': void this.onStackTrace(req, a); return;
|
|
|
+ case 'scopes': this.onScopes(req, a); return;
|
|
|
+ case 'variables': void this.onVariables(req, a); return;
|
|
|
+ case 'evaluate': void this.onEvaluate(req, a); return;
|
|
|
+ case 'continue': void this.onContinue(req); return;
|
|
|
+ case 'next': void this.onStep(req, '-exec-next'); return;
|
|
|
+ case 'stepIn': void this.onStep(req, '-exec-step'); return;
|
|
|
+ case 'stepOut': void this.onStep(req, '-exec-finish'); return;
|
|
|
+ case 'pause': void this.onPause(req); return;
|
|
|
+ case 'disconnect': void this.onDisconnect(req); return;
|
|
|
+ default:
|
|
|
+ this.respond(req, undefined, false, `unsupported request: ${req.command}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onLaunch(req: DapMessage, args: Record<string, any>): Promise<void> {
|
|
|
+ const program = String(args['program'] ?? '');
|
|
|
+ if (!program) {
|
|
|
+ this.respond(req, undefined, false, 'launch: missing "program" (set it in launch.json or run Pascal: Build first)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ fs.accessSync(program, fs.constants.X_OK);
|
|
|
+ } catch {
|
|
|
+ this.respond(req, undefined, false, `launch: program not found or not executable: ${program} (build it with Pascal: Build, including -g for source lines)`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.program = program;
|
|
|
+ this.lastLaunchArgs = args;
|
|
|
+ this.moduleVars = readModuleGlobals(program);
|
|
|
+ this.gdb = spawn('gdb', ['--interpreter=mi2'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
|
+ this.gdb.stdout?.on('data', d => this.onGdbData(d.toString()));
|
|
|
+ this.gdb.stderr?.on('data', d => this.event('output', { category: 'stderr', output: d.toString() }));
|
|
|
+ this.gdb.on('close', () => this.onGdbClosed());
|
|
|
+ this.gdb.on('error', err => {
|
|
|
+ if (!this.exited) this.respond(req, undefined, false, `launch: cannot start gdb: ${String(err)}`);
|
|
|
+ });
|
|
|
+ // Let the process start before issuing MI commands.
|
|
|
+ await new Promise(r => setTimeout(r, 150));
|
|
|
+ if (!this.gdb) {
|
|
|
+ this.respond(req, undefined, false, 'launch: gdb failed to start');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await this.mi('-gdb-set confirm off');
|
|
|
+ const file = await this.mi(`-file-exec-and-symbols "${program.replace(/"/g, '\\"')}"`);
|
|
|
+ if (!this.miOk(file)) {
|
|
|
+ this.respond(req, undefined, false, `launch: ${this.miError(file)}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const cwd = String(args['cwd'] ?? '');
|
|
|
+ if (cwd) await this.mi(`-environment-cd "${cwd.replace(/"/g, '\\"')}"`);
|
|
|
+ const extra = Array.isArray(args['args']) ? args['args'].map(String) : [];
|
|
|
+ if (extra.length > 0) {
|
|
|
+ await this.mi(`-exec-arguments ${extra.map(s => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s)).join(' ')}`);
|
|
|
+ }
|
|
|
+ this.respond(req, {});
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onSetBreakpoints(req: DapMessage, args: Record<string, any>): Promise<void> {
|
|
|
+ const source = args['source'] as { path?: string } | undefined;
|
|
|
+ const file = source?.path;
|
|
|
+ const wanted = (Array.isArray(args['breakpoints']) ? args['breakpoints'] : []) as Array<{ line: number; condition?: string }>;
|
|
|
+ if (!file) {
|
|
|
+ this.respond(req, { breakpoints: [] });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ids: Breakpoint[] = [];
|
|
|
+ if (this.gdb) {
|
|
|
+ for (const old of this.breakpoints.get(file) ?? []) {
|
|
|
+ await this.mi(`-break-delete ${old.number}`);
|
|
|
+ }
|
|
|
+ for (const bp of wanted) {
|
|
|
+ const ins = await this.mi(`-break-insert -f "${file}:${bp.line}"`);
|
|
|
+ if (!this.miOk(ins)) {
|
|
|
+ ids.push({ line: bp.line, number: 0 });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const bkpt = asFields(ins.fields['bkpt']);
|
|
|
+ const number = Number(fieldString(bkpt ?? {}, 'number') ?? '0');
|
|
|
+ if (bp.condition) await this.mi(`-break-condition ${number} ${bp.condition}`);
|
|
|
+ ids.push({ line: bp.line, number, condition: bp.condition });
|
|
|
+ }
|
|
|
+ this.breakpoints.set(file, ids.filter(b => b.number > 0));
|
|
|
+ } else {
|
|
|
+ this.breakpoints.set(file, wanted.map(b => ({ line: b.line, number: 0, condition: b.condition })));
|
|
|
+ }
|
|
|
+ this.respond(req, {
|
|
|
+ breakpoints: ids.length > 0
|
|
|
+ ? ids.map(b => ({ id: b.number > 0 ? b.number : undefined, verified: b.number > 0, line: b.line }))
|
|
|
+ : wanted.map(b => ({ verified: false, line: b.line })),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onConfigurationDone(req: DapMessage): Promise<void> {
|
|
|
+ this.respond(req, {});
|
|
|
+ if (this.started || !this.gdb) return;
|
|
|
+ this.started = true;
|
|
|
+ // Breakpoints set before launch resolve now (normally already applied).
|
|
|
+ for (const [file, bps] of this.breakpoints) {
|
|
|
+ if (bps.some(b => b.number === 0)) {
|
|
|
+ for (const old of bps) {
|
|
|
+ if (old.number > 0) await this.mi(`-break-delete ${old.number}`);
|
|
|
+ }
|
|
|
+ const fresh: Breakpoint[] = [];
|
|
|
+ for (const bp of bps) {
|
|
|
+ const ins = await this.mi(`-break-insert -f "${file}:${bp.line}"`);
|
|
|
+ const bkpt = asFields(ins.fields['bkpt']);
|
|
|
+ const number = Number(fieldString(bkpt ?? {}, 'number') ?? '0');
|
|
|
+ if (number > 0) {
|
|
|
+ if (bp.condition) await this.mi(`-break-condition ${number} ${bp.condition}`);
|
|
|
+ fresh.push({ line: bp.line, number, condition: bp.condition });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.breakpoints.set(file, fresh);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Temporary entry breakpoint when requested (best effort: plain `main` first).
|
|
|
+ const stopOnEntry = (this.lastLaunchArgs['stopOnEntry'] as boolean) ?? false;
|
|
|
+ if (stopOnEntry) {
|
|
|
+ const ins = await this.mi('-break-insert -t main');
|
|
|
+ const bkpt = asFields(ins.fields['bkpt']);
|
|
|
+ this.entryBp = Number(fieldString(bkpt ?? {}, 'number') ?? '0') || null;
|
|
|
+ }
|
|
|
+ this.running = true;
|
|
|
+ await this.mi('-exec-run');
|
|
|
+ }
|
|
|
+
|
|
|
+ private lastLaunchArgs: Record<string, unknown> = {};
|
|
|
+
|
|
|
+ private async onThreads(req: DapMessage): Promise<void> {
|
|
|
+ if (this.gdb && !this.running) {
|
|
|
+ const rec = await this.mi('-thread-info');
|
|
|
+ if (this.miOk(rec)) this.threadsCache = this.parseThreads(rec);
|
|
|
+ }
|
|
|
+ this.respond(req, { threads: this.threadsCache.map(t => ({ id: t.id, name: t.name })) });
|
|
|
+ }
|
|
|
+
|
|
|
+ private parseThreads(rec: MIRecord): Array<{ id: number; name: string }> {
|
|
|
+ const out: Array<{ id: number; name: string }> = [];
|
|
|
+ for (const item of asList(rec.fields['threads'])) {
|
|
|
+ const f = asFields(item);
|
|
|
+ if (!f) continue;
|
|
|
+ const id = Number(fieldString(f, 'id') ?? '0') || 0;
|
|
|
+ out.push({ id, name: `Thread ${fieldString(f, 'id')}` });
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onStackTrace(req: DapMessage, args: Record<string, any>): Promise<void> {
|
|
|
+ const threadId = Number(args['threadId'] ?? 1);
|
|
|
+ if (!this.gdb || this.running) {
|
|
|
+ this.respond(req, undefined, false, 'not stopped');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const rec = await this.mi(`-stack-list-frames --thread ${threadId}`);
|
|
|
+ if (!this.miOk(rec)) {
|
|
|
+ this.respond(req, undefined, false, this.miError(rec));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const frames: unknown[] = [];
|
|
|
+ for (const item of asList(rec.fields['stack'])) {
|
|
|
+ const wrapped = asFields(item);
|
|
|
+ const f = wrapped && 'frame' in wrapped ? asFields(wrapped['frame']) : wrapped;
|
|
|
+ if (!f) continue;
|
|
|
+ const level = Number(fieldString(f, 'level') ?? '0');
|
|
|
+ const id = threadId * 1000000 + level;
|
|
|
+ this.frames.set(id, { threadId, level });
|
|
|
+ const file = fieldString(f, 'fullname') ?? fieldString(f, 'file');
|
|
|
+ const line = Number(fieldString(f, 'line') ?? '0');
|
|
|
+ frames.push({
|
|
|
+ id,
|
|
|
+ name: fieldString(f, 'func') ?? `frame ${level}`,
|
|
|
+ source: file ? { name: path.basename(file), path: file } : undefined,
|
|
|
+ line: line > 0 ? line : 0,
|
|
|
+ column: 0,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.respond(req, { stackFrames: frames, totalFrames: frames.length });
|
|
|
+ }
|
|
|
+
|
|
|
+ private onScopes(req: DapMessage, args: Record<string, any>): void {
|
|
|
+ const ref = this.frames.get(Number(args['frameId']));
|
|
|
+ if (!ref || !this.gdb || this.running) {
|
|
|
+ this.respond(req, undefined, false, 'not stopped');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const handle = this.nextHandle++;
|
|
|
+ this.handles.set(handle, { kind: 'locals', threadId: ref.threadId, level: ref.level, frameId: Number(args['frameId']) });
|
|
|
+ const scopes: unknown[] = [{ name: 'Locals', variablesReference: handle, expensive: false }];
|
|
|
+ if (this.moduleVars.length > 0) {
|
|
|
+ const modHandle = this.nextHandle++;
|
|
|
+ this.handles.set(modHandle, { kind: 'module' });
|
|
|
+ scopes.push({ name: 'Module', variablesReference: modHandle, expensive: false });
|
|
|
+ }
|
|
|
+ this.respond(req, { scopes });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onVariables(req: DapMessage, args: Record<string, any>): Promise<void> {
|
|
|
+ const handle = this.handles.get(Number(args['variablesReference']));
|
|
|
+ if (!handle || !this.gdb || this.running) {
|
|
|
+ this.respond(req, undefined, false, 'not stopped');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (handle.kind === 'module') {
|
|
|
+ const out: unknown[] = [];
|
|
|
+ for (const name of this.moduleVars) {
|
|
|
+ const made = await this.makeVarObj(name);
|
|
|
+ out.push({ name, value: made?.value ?? '', type: made?.type ?? '', variablesReference: made?.ref ?? 0 });
|
|
|
+ }
|
|
|
+ this.respond(req, { variables: out });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (handle.kind === 'locals') {
|
|
|
+ const rec = await this.mi(`-stack-list-variables --thread ${handle.threadId} --frame ${handle.level} --simple-values`);
|
|
|
+ if (!this.miOk(rec)) {
|
|
|
+ this.respond(req, undefined, false, this.miError(rec));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const out: unknown[] = [];
|
|
|
+ for (const item of asList(rec.fields['variables'])) {
|
|
|
+ const v = asFields(item);
|
|
|
+ if (!v) continue;
|
|
|
+ const name = fieldString(v, 'name') ?? '?';
|
|
|
+ const value = fieldString(v, 'value') ?? '';
|
|
|
+ const type = fieldString(v, 'type') ?? '';
|
|
|
+ let ref = 0;
|
|
|
+ if (!SCALAR_TYPES.has(type.toUpperCase()) && value !== '') {
|
|
|
+ ref = (await this.makeVarObj(name))?.ref ?? 0;
|
|
|
+ }
|
|
|
+ out.push({ name, value, type, variablesReference: ref });
|
|
|
+ }
|
|
|
+ this.respond(req, { variables: out });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const rec = await this.mi(`-var-list-children --all-values ${handle.name}`);
|
|
|
+ if (!this.miOk(rec)) {
|
|
|
+ this.respond(req, undefined, false, this.miError(rec));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const childrenField = rec.fields['children'];
|
|
|
+ const rawChildren = Array.isArray(childrenField)
|
|
|
+ ? childrenField
|
|
|
+ : asList(asFields(childrenField)?.['child']);
|
|
|
+ const out: unknown[] = [];
|
|
|
+ for (const item of rawChildren) {
|
|
|
+ const wrapped = asFields(item);
|
|
|
+ // gdb emits children=[child={...},child={...}]; unwrap single-key wrappers.
|
|
|
+ const v = wrapped && 'child' in wrapped ? asFields(wrapped['child']) ?? wrapped : wrapped;
|
|
|
+ if (!v) continue;
|
|
|
+ const name = fieldString(v, 'name') ?? '?';
|
|
|
+ const exp = fieldString(v, 'exp') ?? name;
|
|
|
+ const value = fieldString(v, 'value') ?? '';
|
|
|
+ const type = fieldString(v, 'type') ?? '';
|
|
|
+ const numchild = fieldString(v, 'numchild') ?? '0';
|
|
|
+ let ref = 0;
|
|
|
+ if (numchild !== '0') {
|
|
|
+ const h = this.nextHandle++;
|
|
|
+ this.handles.set(h, { kind: 'varobj', name });
|
|
|
+ ref = h;
|
|
|
+ }
|
|
|
+ out.push({ name: exp, value, type, variablesReference: ref });
|
|
|
+ }
|
|
|
+ this.respond(req, { variables: out });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async makeVarObj(expression: string): Promise<{ ref: number; value: string; type: string } | null> {
|
|
|
+ this.varSeq++;
|
|
|
+ const name = `m2v${this.varSeq}`;
|
|
|
+ const rec = await this.mi(`-var-create ${name} * "${expression.replace(/"/g, '\\"')}"`);
|
|
|
+ if (!this.miOk(rec)) return null;
|
|
|
+ const value = fieldString(rec.fields, 'value') ?? '';
|
|
|
+ const type = fieldString(rec.fields, 'type') ?? '';
|
|
|
+ if ((fieldString(rec.fields, 'numchild') ?? '0') === '0') {
|
|
|
+ void this.mi(`-var-delete ${name}`);
|
|
|
+ return { ref: 0, value, type };
|
|
|
+ }
|
|
|
+ this.varObjs.add(name);
|
|
|
+ const h = this.nextHandle++;
|
|
|
+ this.handles.set(h, { kind: 'varobj', name });
|
|
|
+ return { ref: h, value, type };
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onEvaluate(req: DapMessage, args: Record<string, any>): Promise<void> {
|
|
|
+ const expression = String(args['expression'] ?? '');
|
|
|
+ if (!expression || !this.gdb || this.running) {
|
|
|
+ this.respond(req, undefined, false, expression ? 'not stopped' : 'no expression');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const frame = this.frames.get(Number(args['frameId'] ?? 0));
|
|
|
+ if (frame) await this.mi(`-stack-select-frame ${frame.level} --thread ${frame.threadId}`);
|
|
|
+ const rec = await this.mi(`-data-evaluate-expression "${expression.replace(/"/g, '\\"')}"`);
|
|
|
+ if (!this.miOk(rec)) {
|
|
|
+ this.respond(req, undefined, false, this.miError(rec));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.respond(req, { result: fieldString(rec.fields, 'value') ?? '', variablesReference: 0 });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onContinue(req: DapMessage): Promise<void> {
|
|
|
+ if (!this.gdb) {
|
|
|
+ this.respond(req, undefined, false, 'not started');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.running = true;
|
|
|
+ this.respond(req, { allThreadsContinued: true });
|
|
|
+ void this.mi('-exec-continue');
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onStep(req: DapMessage, command: string): Promise<void> {
|
|
|
+ if (!this.gdb || this.running) {
|
|
|
+ this.respond(req, undefined, false, this.running ? 'already running' : 'not started');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.running = true;
|
|
|
+ this.respond(req, {});
|
|
|
+ void this.mi(command);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onPause(req: DapMessage): Promise<void> {
|
|
|
+ if (!this.gdb) {
|
|
|
+ this.respond(req, undefined, false, 'not started');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.respond(req, {});
|
|
|
+ void this.mi('-exec-interrupt');
|
|
|
+ }
|
|
|
+
|
|
|
+ private async onDisconnect(req: DapMessage): Promise<void> {
|
|
|
+ this.respond(req, {});
|
|
|
+ await this.shutdown();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async shutdown(): Promise<void> {
|
|
|
+ if (this.exited) return;
|
|
|
+ this.exited = true;
|
|
|
+ try {
|
|
|
+ if (this.gdb) {
|
|
|
+ this.gdb.stdin?.write('-gdb-exit\n');
|
|
|
+ await new Promise(r => setTimeout(r, 250));
|
|
|
+ this.gdb.kill('SIGKILL');
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // Best effort.
|
|
|
+ }
|
|
|
+ setTimeout(() => process.exit(0), 100);
|
|
|
+ }
|
|
|
+
|
|
|
+ private onGdbClosed(): void {
|
|
|
+ this.pending.clear();
|
|
|
+ if (!this.exited) {
|
|
|
+ this.event('terminated', { restart: false });
|
|
|
+ void this.shutdown();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const adapter = new Adapter();
|
|
|
+adapter.run();
|