feat(debugger): integrate NX Tcl Remote Debugger into NX Postprocessor
build_and_puplish.yml / build_and_publish (release) Successful in 35s
build_and_puplish.yml / build_and_publish (release) Successful in 35s
Embed the NX Tcl Remote Debugger into the NX Postprocessor extension. Add a new debugging client, protocol, and adapter logic to drive attach/launch, breakpoints, stepping, and evaluation via the runtime adapter. - Introduced a client debugger with a new adapter and protocol - Wired attach/launch, breakpoints, stepping, and evaluation - Updated docs and licensing to reflect the embedded debugger
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
'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 || '<Tcl>',
|
||||
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: '<NX is running; evaluation is available while paused>',
|
||||
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: '<stack frame is no longer available>',
|
||||
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 };
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { NxDebugAdapter } = require('./debugAdapter');
|
||||
|
||||
function frameMessage(message) {
|
||||
const json = JSON.stringify(message);
|
||||
return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
|
||||
}
|
||||
|
||||
class NxInlineDebugAdapter {
|
||||
constructor() {
|
||||
this.input = new PassThrough();
|
||||
this.output = new PassThrough();
|
||||
this.outputBuffer = Buffer.alloc(0);
|
||||
this.listeners = new Set();
|
||||
this.disposed = false;
|
||||
|
||||
this.onDidSendMessage = (listener, thisArgs, disposables) => {
|
||||
const registration = { listener, thisArgs };
|
||||
this.listeners.add(registration);
|
||||
const disposable = {
|
||||
dispose: () => this.listeners.delete(registration),
|
||||
};
|
||||
if (Array.isArray(disposables)) disposables.push(disposable);
|
||||
return disposable;
|
||||
};
|
||||
|
||||
this.output.on('data', (chunk) => this.onOutput(chunk));
|
||||
this.adapter = new NxDebugAdapter(this.input, this.output);
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
if (!this.disposed) this.input.write(frameMessage(message));
|
||||
}
|
||||
|
||||
onOutput(chunk) {
|
||||
this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]);
|
||||
while (true) {
|
||||
const headerEnd = this.outputBuffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd < 0) return;
|
||||
const header = this.outputBuffer.subarray(0, headerEnd).toString('ascii');
|
||||
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
||||
if (!match) throw new Error('missing DAP Content-Length header from NX adapter');
|
||||
const contentLength = Number(match[1]);
|
||||
const messageEnd = headerEnd + 4 + contentLength;
|
||||
if (this.outputBuffer.length < messageEnd) return;
|
||||
|
||||
const payload = this.outputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8');
|
||||
this.outputBuffer = this.outputBuffer.subarray(messageEnd);
|
||||
const message = JSON.parse(payload);
|
||||
for (const { listener, thisArgs } of [...this.listeners]) {
|
||||
listener.call(thisArgs, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.adapter.dispose();
|
||||
this.input.end();
|
||||
this.output.destroy();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { NxInlineDebugAdapter, frameMessage };
|
||||
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
const net = require('net');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
function encodeHex(value) {
|
||||
return Buffer.from(String(value ?? ''), 'utf8').toString('hex') || '-';
|
||||
}
|
||||
|
||||
function decodeHex(value) {
|
||||
if (!value || value === '-') return '';
|
||||
return Buffer.from(value, 'hex').toString('utf8');
|
||||
}
|
||||
|
||||
class RuntimeConnection extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.socket = null;
|
||||
this.buffer = '';
|
||||
this.nextRequestId = 1;
|
||||
this.pending = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
connect(host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const onError = (error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
socket.once('error', onError);
|
||||
socket.once('connect', () => {
|
||||
socket.removeListener('error', onError);
|
||||
this.socket = socket;
|
||||
this.closed = false;
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk) => this.onData(chunk));
|
||||
socket.on('error', (error) => this.emit('runtimeError', error));
|
||||
socket.on('close', () => this.onClose());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
request(command, fields = [], timeoutMs = 30000) {
|
||||
if (!this.socket || this.closed) {
|
||||
return Promise.reject(new Error('NX runtime is not connected'));
|
||||
}
|
||||
const id = this.nextRequestId++;
|
||||
const normalized = fields.map((field) => String(field ?? ''));
|
||||
if (normalized.some((field) => field.includes('\t') || field.includes('\n'))) {
|
||||
return Promise.reject(new Error('protocol fields must be hex-encoded before transmission'));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`NX ${command} request timed out`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer, command });
|
||||
this.socket.write(['REQ', id, command, ...normalized].join('\t') + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
if (this.socket) this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
onData(chunk) {
|
||||
this.buffer += chunk;
|
||||
let newline;
|
||||
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
||||
const line = this.buffer.slice(0, newline).replace(/\r$/, '');
|
||||
this.buffer = this.buffer.slice(newline + 1);
|
||||
if (line) this.onLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
onLine(line) {
|
||||
const fields = line.split('\t');
|
||||
if (fields[0] === 'RES') {
|
||||
const id = Number(fields[1]);
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(pending.timer);
|
||||
if (fields[2] === 'OK') {
|
||||
pending.resolve({ command: fields[3], fields: fields.slice(4) });
|
||||
} else {
|
||||
pending.reject(new Error(decodeHex(fields[4]) || `NX ${pending.command} failed`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (fields[0] === 'EVENT') {
|
||||
this.emit('event', fields[1], fields.slice(2));
|
||||
return;
|
||||
}
|
||||
this.emit('runtimeError', new Error(`unrecognized NX message: ${line}`));
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('NX runtime disconnected'));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RuntimeConnection, encodeHex, decodeHex };
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// The adapter is shared with the standalone NX Tcl Remote Debugger. It is
|
||||
// bundled by esbuild into this extension, so no child process or additional
|
||||
// VS Code extension is required.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { NxInlineDebugAdapter } = require("./inlineAdapter")
|
||||
|
||||
class NxDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory {
|
||||
createDebugAdapterDescriptor(): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
|
||||
return new vscode.DebugAdapterInlineImplementation(new NxInlineDebugAdapter())
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDebugConfiguration(
|
||||
folder: vscode.WorkspaceFolder | undefined,
|
||||
config: vscode.DebugConfiguration
|
||||
): vscode.DebugConfiguration {
|
||||
const resolved = { ...config }
|
||||
if (!resolved.type) resolved.type = "nx-tcl"
|
||||
if (!resolved.request) resolved.request = "attach"
|
||||
if (!resolved.name) resolved.name = "Attach to NX Post Tcl"
|
||||
if (!resolved.host) resolved.host = "127.0.0.1"
|
||||
if (!resolved.port) resolved.port = 4711
|
||||
if (!resolved.connectTimeout) resolved.connectTimeout = 120000
|
||||
if (resolved.stopOnEntry === undefined) resolved.stopOnEntry = false
|
||||
if (resolved.breakOnError === undefined) resolved.breakOnError = true
|
||||
if (!resolved.localRoot && folder) resolved.localRoot = folder.uri.fsPath
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function registerNxTclDebugger(context: vscode.ExtensionContext): void {
|
||||
const factory = new NxDebugAdapterFactory()
|
||||
context.subscriptions.push(
|
||||
vscode.debug.registerDebugAdapterDescriptorFactory("nx-tcl", factory),
|
||||
vscode.debug.registerDebugConfigurationProvider("nx-tcl", {
|
||||
resolveDebugConfiguration
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export { resolveDebugConfiguration }
|
||||
@@ -29,10 +29,13 @@ import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common
|
||||
import { loadServerDefaults } from "./common/setup"
|
||||
import { getLSClientTraceLevel } from "./common/utilities"
|
||||
import { createOutputChannel, onDidChangeConfiguration, registerCommand } from "./common/vscodeapi"
|
||||
import { registerNxTclDebugger } from "./debugger/register"
|
||||
|
||||
let client: LanguageClient | undefined
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
registerNxTclDebugger(context)
|
||||
|
||||
// This is required to get server name and module. This should be
|
||||
// the first thing that we do in this extension.
|
||||
const serverInfo = loadServerDefaults()
|
||||
|
||||
Reference in New Issue
Block a user