'use strict'; const path = require('path'); const { RuntimeConnection, encodeHex, decodeHex } = require('./protocol'); const THREAD_ID = 1; class NxDebugAdapter { constructor(input = process.stdin, output = process.stdout) { this.input = input; this.output = output; this.inputBuffer = Buffer.alloc(0); this.sequence = 1; this.runtime = null; this.terminated = false; this.explicitDisconnect = false; this.localRoot = ''; this.remoteRoot = ''; this.nextBreakpointId = 1; // VS Code can expose the same physical file through more than one source // identity (for example, drive mappings or differently cased workspace // roots). SETBPS replaces all breakpoints for a runtime path, so retain // each DAP source group and send their union to NX. this.sourceBreakpointGroups = new Map(); this.pendingStartRequest = null; this.resumeInProgress = 0; this.pendingStoppedEvents = []; this.isPaused = false; // DAP frame/variable IDs can outlive a stop in the VS Code UI. Runtime // levels and references, however, are valid only for the current Tcl // pause. Give every stop adapter-owned IDs so a late request can never // accidentally address a same-numbered frame from a later pause. this.nextFrameId = 1; this.frameReferences = new Map(); this.runtimeToFrameReference = new Map(); this.nextVariablesReference = 1; this.variableReferences = new Map(); this.runtimeToVariableReference = new Map(); input.on('data', (chunk) => this.onData(chunk)); input.on('end', () => this.dispose()); input.resume(); } onData(chunk) { this.inputBuffer = Buffer.concat([this.inputBuffer, chunk]); while (true) { const headerEnd = this.inputBuffer.indexOf('\r\n\r\n'); if (headerEnd < 0) return; const header = this.inputBuffer.subarray(0, headerEnd).toString('ascii'); const match = /Content-Length:\s*(\d+)/i.exec(header); if (!match) throw new Error('missing DAP Content-Length header'); const contentLength = Number(match[1]); const messageEnd = headerEnd + 4 + contentLength; if (this.inputBuffer.length < messageEnd) return; const payload = this.inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8'); this.inputBuffer = this.inputBuffer.subarray(messageEnd); const message = JSON.parse(payload); if (message.type === 'request') { this.handleRequest(message).catch((error) => this.sendErrorResponse(message, error)); } } } send(message) { message.seq = this.sequence++; const json = JSON.stringify(message); this.output.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`); } sendResponse(request, body = {}) { this.send({ type: 'response', request_seq: request.seq, success: true, command: request.command, body, }); } sendErrorResponse(request, error) { this.send({ type: 'response', request_seq: request.seq, success: false, command: request.command, message: error instanceof Error ? error.message : String(error), }); } sendEvent(event, body = {}) { this.send({ type: 'event', event, body }); } async handleRequest(request) { const args = request.arguments || {}; switch (request.command) { case 'initialize': this.sendResponse(request, { supportsConfigurationDoneRequest: true, supportsConditionalBreakpoints: true, supportsHitConditionalBreakpoints: true, supportsLogPoints: true, supportsEvaluateForHovers: true, supportsSetVariable: true, supportsExceptionFilterOptions: true, exceptionBreakpointFilters: [ { filter: 'tclError', label: 'Tcl errors', description: 'Break when a Tcl command returns TCL_ERROR' }, ], supportsTerminateRequest: true, }); break; case 'attach': case 'launch': await this.attach(args); this.pendingStartRequest = request; this.sendEvent('initialized'); break; case 'configurationDone': await this.runtime.request('CONFIGDONE'); this.sendResponse(request); if (this.pendingStartRequest) { this.sendResponse(this.pendingStartRequest); this.pendingStartRequest = null; } break; case 'setBreakpoints': await this.setBreakpoints(request, args); break; case 'setExceptionBreakpoints': { const filters = args.filters || []; await this.runtime.request('SETEXCEPTIONS', filters); this.sendResponse(request, { breakpoints: filters.map(() => ({ verified: true })), }); break; } case 'threads': this.sendResponse(request, { threads: [{ id: THREAD_ID, name: 'NX Post Tcl' }] }); break; case 'stackTrace': await this.stackTrace(request); break; case 'scopes': await this.scopes(request, args); break; case 'variables': await this.variables(request, args); break; case 'evaluate': await this.evaluate(request, args); break; case 'setVariable': await this.setVariable(request, args); break; case 'continue': await this.resume(request, 'CONTINUE', { allThreadsContinued: true }); break; case 'next': await this.resume(request, 'NEXT'); break; case 'stepIn': await this.resume(request, 'STEPIN'); break; case 'stepOut': await this.resume(request, 'STEPOUT'); break; case 'pause': await this.runtime.request('PAUSE'); this.sendResponse(request); break; case 'disconnect': case 'terminate': await this.disconnect(request); break; default: this.sendResponse(request); break; } } async attach(args) { const host = args.host || '127.0.0.1'; const port = Number(args.port || 4711); const timeout = Number(args.connectTimeout || 120000); this.localRoot = args.localRoot ? path.resolve(args.localRoot) : ''; this.remoteRoot = args.remoteRoot ? this.normalizePath(args.remoteRoot) : ''; this.sourceBreakpointGroups.clear(); this.isPaused = false; this.resetStoppedReferences(true); this.runtime = new RuntimeConnection(); this.runtime.on('event', (name, fields) => this.onRuntimeEvent(name, fields)); this.runtime.on('runtimeError', (error) => { this.sendEvent('output', { category: 'stderr', output: `NX: ${error.message}\n` }); }); this.runtime.on('close', () => { if (!this.explicitDisconnect) this.sendTerminated(); }); const started = Date.now(); let lastError; while (Date.now() - started < timeout) { try { await this.runtime.connect(host, port); lastError = null; break; } catch (error) { lastError = error; await new Promise((resolve) => setTimeout(resolve, 250)); } } if (lastError) { throw new Error(`could not connect to NX at ${host}:${port}: ${lastError.message}`); } await this.runtime.request('CONFIG', [args.stopOnEntry ? 1 : 0, args.breakOnError ? 1 : 0]); } async setBreakpoints(request, args) { const sourcePath = args.source && args.source.path ? args.source.path : ''; const remotePath = this.toRemotePath(sourcePath); const sourceKey = this.breakpointSourceKey(args.source, sourcePath); const remoteKey = this.breakpointRemoteKey(remotePath); const requested = args.breakpoints || (args.lines || []).map((line) => ({ line })); const records = requested.map((breakpoint) => ({ id: this.nextBreakpointId++, line: breakpoint.line, condition: breakpoint.condition || '', hitCondition: breakpoint.hitCondition || '', logMessage: breakpoint.logMessage || '', })); if (!this.isTclSource(sourcePath)) { this.sourceBreakpointGroups.delete(sourceKey); // VS Code forwards breakpoints from every language to every active debug // session. Clear any stale runtime entry left by an older adapter, but do // not make the NX Tcl runtime parse or instrument a Python source file. await this.runtime.request('SETBPS', [encodeHex(remotePath), 0]); this.sendResponse(request, { breakpoints: records.map((breakpoint) => ({ id: breakpoint.id, verified: false, line: breakpoint.line, source: args.source, message: 'NX Tcl Remote Debugger supports breakpoints only in .tcl and .def files', })), }); return; } this.sourceBreakpointGroups.set(sourceKey, { remotePath, remoteKey, records }); // Collapse exact duplicates shared by aliases, while remembering which // runtime breakpoint represents every DAP breakpoint ID. This keeps hit // counts and log points from firing twice for one Tcl command. const mergedBreakpoints = []; const mergedBySignature = new Map(); const representativeById = new Map(); for (const group of this.sourceBreakpointGroups.values()) { if (group.remoteKey !== remoteKey) continue; for (const breakpoint of group.records) { const signature = this.breakpointSignature(breakpoint); let representative = mergedBySignature.get(signature); if (!representative) { representative = breakpoint; mergedBySignature.set(signature, representative); mergedBreakpoints.push(representative); } representativeById.set(breakpoint.id, representative.id); } } const fields = [encodeHex(remotePath), mergedBreakpoints.length]; for (const breakpoint of mergedBreakpoints) { fields.push( breakpoint.id, breakpoint.line, encodeHex(breakpoint.condition), encodeHex(breakpoint.hitCondition), encodeHex(breakpoint.logMessage), ); } const runtimeResponse = await this.runtime.request('SETBPS', fields); const responseFields = runtimeResponse.fields || []; const responseCount = Number(responseFields[0] || 0); const responseStride = responseFields.length >= 1 + responseCount * 4 ? 4 : 2; const runtimeBreakpoints = new Map(); let responseCursor = 1; for (let index = 0; index < responseCount; index++) { const id = Number(responseFields[responseCursor++]); const line = Number(responseFields[responseCursor++]); let verified = true; let message = ''; if (responseStride === 4) { verified = responseFields[responseCursor++] === '1'; message = decodeHex(responseFields[responseCursor++]); } runtimeBreakpoints.set(id, { line, verified, message }); } this.sendResponse(request, { breakpoints: records.map((breakpoint) => { const representativeId = representativeById.get(breakpoint.id); const runtimeBreakpoint = runtimeBreakpoints.get(representativeId); const result = { id: breakpoint.id, verified: runtimeBreakpoint ? runtimeBreakpoint.verified : false, line: runtimeBreakpoint && runtimeBreakpoint.line ? runtimeBreakpoint.line : breakpoint.line, source: args.source, }; if (runtimeBreakpoint && runtimeBreakpoint.message) result.message = runtimeBreakpoint.message; return result; }), }); } async stackTrace(request) { if (!this.isPaused) { this.sendResponse(request, { stackFrames: [], totalFrames: 0 }); return; } const { fields } = await this.runtime.request('STACK'); const count = Number(fields[0] || 0); const stackFrames = []; let cursor = 1; for (let index = 0; index < count; index++) { const runtimeId = Number(fields[cursor++]); const name = decodeHex(fields[cursor++]); const remoteFile = decodeHex(fields[cursor++]); const line = Number(fields[cursor++]); const column = Number(fields[cursor++]); const localFile = this.toLocalPath(remoteFile); const frame = { id: this.toAdapterFrameId(runtimeId), name: name || '', line: line || 1, column: column || 1, }; if (localFile) frame.source = { name: path.basename(localFile), path: localFile }; stackFrames.push(frame); } this.sendResponse(request, { stackFrames, totalFrames: stackFrames.length }); } async scopes(request, args) { const runtimeFrameId = this.frameReferences.get(Number(args.frameId)); if (!this.isPaused || runtimeFrameId === undefined) { this.sendResponse(request, { scopes: [] }); return; } const { fields } = await this.runtime.request('SCOPES', [runtimeFrameId]); const count = Number(fields[0] || 0); const scopes = []; let cursor = 1; for (let index = 0; index < count; index++) { const variablesReference = this.toAdapterVariablesReference(Number(fields[cursor++])); const name = decodeHex(fields[cursor++]); cursor++; // wire label: expensive const expensive = fields[cursor++] === '1'; scopes.push({ name, variablesReference, expensive }); } this.sendResponse(request, { scopes }); } async variables(request, args) { const runtimeReference = this.variableReferences.get(Number(args.variablesReference)); if (!this.isPaused || runtimeReference === undefined) { this.sendResponse(request, { variables: [] }); return; } const { fields } = await this.runtime.request('VARIABLES', [ runtimeReference, args.start || 0, args.count || 0, encodeHex(args.filter || ''), ]); const count = Number(fields[0] || 0); const variables = []; let cursor = 1; for (let index = 0; index < count; index++) { variables.push({ name: decodeHex(fields[cursor++]), value: decodeHex(fields[cursor++]), type: decodeHex(fields[cursor++]), variablesReference: this.toAdapterVariablesReference(Number(fields[cursor++])), indexedVariables: Number(fields[cursor++]) || undefined, namedVariables: Number(fields[cursor++]) || undefined, }); } this.sendResponse(request, { variables }); } async evaluate(request, args) { // VS Code can refresh Watch/Hover expressions after Continue but before // it has processed the next stopped event. Do not forward that transient // request to the running NX interpreter (which correctly rejects it). if (!this.isPaused) { this.sendResponse(request, { result: '', type: 'unavailable', variablesReference: 0, }); return; } let runtimeFrameId = 0; if (Number(args.frameId) > 0) { runtimeFrameId = this.frameReferences.get(Number(args.frameId)); if (runtimeFrameId === undefined) { this.sendResponse(request, { result: '', type: 'unavailable', variablesReference: 0, }); return; } } const { fields } = await this.runtime.request('EVALUATE', [runtimeFrameId, encodeHex(args.expression || '')]); this.sendResponse(request, { result: decodeHex(fields[0]), type: decodeHex(fields[1]), variablesReference: this.toAdapterVariablesReference(Number(fields[2])), indexedVariables: Number(fields[3]) || undefined, namedVariables: Number(fields[4]) || undefined, }); } async setVariable(request, args) { const runtimeReference = this.variableReferences.get(Number(args.variablesReference)); if (!this.isPaused || runtimeReference === undefined) { throw new Error('variable is no longer available'); } const { fields } = await this.runtime.request('SETVARIABLE', [ runtimeReference, encodeHex(args.name), encodeHex(args.value), ]); this.sendResponse(request, { value: decodeHex(fields[0]), type: decodeHex(fields[1]), variablesReference: this.toAdapterVariablesReference(Number(fields[2])), indexedVariables: Number(fields[3]) || undefined, namedVariables: Number(fields[4]) || undefined, }); } async resume(request, command, responseBody = {}) { this.isPaused = false; this.resetStoppedReferences(); this.resumeInProgress++; try { await this.runtime.request(command); // The runtime can hit the next breakpoint immediately and place its // STOPPED event in the same TCP packet as this response. Always publish // the DAP resume transition before releasing such a queued stop. this.sendResponse(request, responseBody); this.sendEvent('continued', { threadId: THREAD_ID, allThreadsContinued: true }); } finally { this.resumeInProgress--; if (this.resumeInProgress === 0) this.flushStoppedEvents(); } } async disconnect(request) { this.explicitDisconnect = true; this.isPaused = false; this.resetStoppedReferences(); if (this.runtime && !this.runtime.closed) { try { await this.runtime.request('DISCONNECT', [], 2000); } catch (_) { // The runtime may close immediately after acknowledging detach. } this.runtime.close(); } this.sendResponse(request); this.sendTerminated(); } onRuntimeEvent(name, fields) { switch (name) { case 'HELLO': this.sendEvent('output', { category: 'console', output: `NX runtime ${decodeHex(fields[0])}, Tcl ${decodeHex(fields[1])}, PID ${fields[2]}\n`, }); break; case 'STOPPED': this.isPaused = true; this.resetStoppedReferences(); this.publishStoppedEvent({ reason: decodeHex(fields[0]) || 'pause', threadId: THREAD_ID, allThreadsStopped: true, text: decodeHex(fields[3]) || undefined, }); break; case 'OUTPUT': this.sendEvent('output', { category: 'stdout', output: decodeHex(fields[0]) }); break; default: this.sendEvent('output', { category: 'console', output: `NX event ${name}\n` }); } } publishStoppedEvent(body) { if (this.resumeInProgress > 0) { this.pendingStoppedEvents.push(body); return; } this.sendEvent('stopped', body); } flushStoppedEvents() { const queued = this.pendingStoppedEvents; this.pendingStoppedEvents = []; for (const body of queued) this.sendEvent('stopped', body); } resetStoppedReferences(resetCounters = false) { this.frameReferences.clear(); this.runtimeToFrameReference.clear(); this.variableReferences.clear(); this.runtimeToVariableReference.clear(); if (resetCounters) { this.nextFrameId = 1; this.nextVariablesReference = 1; } } toAdapterFrameId(runtimeId) { const normalized = Number(runtimeId); if (!Number.isFinite(normalized) || normalized <= 0) return 0; let adapterId = this.runtimeToFrameReference.get(normalized); if (adapterId !== undefined) return adapterId; adapterId = this.nextFrameId++; this.runtimeToFrameReference.set(normalized, adapterId); this.frameReferences.set(adapterId, normalized); return adapterId; } toAdapterVariablesReference(runtimeReference) { const normalized = Number(runtimeReference); if (!Number.isFinite(normalized) || normalized <= 0) return 0; let adapterReference = this.runtimeToVariableReference.get(normalized); if (adapterReference !== undefined) return adapterReference; adapterReference = this.nextVariablesReference++; this.runtimeToVariableReference.set(normalized, adapterReference); this.variableReferences.set(adapterReference, normalized); return adapterReference; } isTclSource(sourcePath) { const extension = path.extname(String(sourcePath || '')).toLowerCase(); return extension === '.tcl' || extension === '.def'; } breakpointSourceKey(source = {}, sourcePath = '') { if (source && Number(source.sourceReference) > 0) { return `reference:${source.sourceReference}`; } // Deliberately preserve spelling and case here: they are precisely what // distinguish two VS Code source identities that map to the same runtime // path. Runtime comparison is handled separately below. return `path:${String(sourcePath || '')}`; } breakpointRemoteKey(remotePath) { let normalized = this.normalizePath(remotePath); if (process.platform === 'win32' || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('//')) { normalized = normalized.toLowerCase(); } return normalized; } breakpointSignature(breakpoint) { return JSON.stringify([ Number(breakpoint.line), breakpoint.condition || '', breakpoint.hitCondition || '', breakpoint.logMessage || '', ]); } normalizePath(value) { const normalized = String(value || '').replace(/\\/g, '/'); if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) return normalized; return normalized.replace(/\/+$/, ''); } samePathPrefix(candidate, prefix) { let normalizedCandidate = this.normalizePath(candidate); let normalizedPrefix = this.normalizePath(prefix); if (process.platform === 'win32') { normalizedCandidate = normalizedCandidate.toLowerCase(); normalizedPrefix = normalizedPrefix.toLowerCase(); } if (normalizedCandidate === normalizedPrefix) return true; const boundaryPrefix = normalizedPrefix.endsWith('/') ? normalizedPrefix : `${normalizedPrefix}/`; return normalizedCandidate.startsWith(boundaryPrefix); } toRemotePath(localPath) { const normalized = this.normalizePath(path.resolve(localPath || '.')); const localRoot = this.normalizePath(this.localRoot); if (localRoot && this.remoteRoot && this.samePathPrefix(normalized, localRoot)) { return this.remoteRoot + normalized.slice(localRoot.length); } return normalized; } toLocalPath(remotePath) { const normalized = this.normalizePath(remotePath); if (this.remoteRoot && this.localRoot && this.samePathPrefix(normalized, this.remoteRoot)) { const suffix = normalized.slice(this.remoteRoot.length).replace(/^\//, ''); return path.join(this.localRoot, ...suffix.split('/')); } return process.platform === 'win32' ? normalized.replace(/\//g, '\\') : normalized; } sendTerminated() { if (this.terminated) return; this.terminated = true; this.sendEvent('terminated'); } dispose() { if (this.runtime) this.runtime.close(); } } if (require.main === module) { new NxDebugAdapter(); } module.exports = { NxDebugAdapter, THREAD_ID };