Compare commits

...
2 Commits
Author SHA1 Message Date
Christoph Brandau c3d5116885 feat(debugger): integrate NX Tcl Remote Debugger into NX Postprocessor
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
2026-08-28 22:36:18 +02:00
Christoph 07ccd2d26a Update version to 2026.8.200 2026-08-19 11:51:21 +00:00
12 changed files with 1135 additions and 10 deletions
+4
View File
@@ -9,6 +9,7 @@ vite.config.js
.prettierrc.json
esbuild.js
.gitea
**/.ruff_cache/**
.venv/**
.nox/**
server/.venv/**
@@ -18,4 +19,7 @@ server/.nox/**
**/requirements.in
**/server/src/_debug_server.py
server/noxfile.py
server/.claude/**
server/tests/**
client/**
dist/**/*.map
+6
View File
@@ -1,5 +1,11 @@
## Unreleased
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
- Add generation-safe stack and variable references so delayed VS Code requests cannot address stale NX frames
- Add command-buffer breakpoint handling for `LIB_GE_command_buffer_edit_*` source bodies
- Extend the README with launch configuration, path mapping, and debugger usage
- Prevent truncated TCL inlay hints and add configurable parameter hint modes
- Add inlay hints for built-in NX procedures, variadic arguments, and visible ranges
- Add inlay hint documentation and navigation to custom procedure definitions
+53 -2
View File
@@ -1,6 +1,6 @@
# NX Postprocessor Support
A comprehensive VS Code extension providing language support for NX CAM postprocessor development, including CDL, TCL, and DEF files.
A comprehensive VS Code extension providing language support and remote debugging for NX CAM postprocessor development, including CDL, TCL, and DEF files.
## Features
@@ -10,6 +10,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
- **Auto-completion** - Context-aware code completion for faster development
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
## Supported File Types
@@ -24,6 +25,10 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
3. Open any `.cdl`, `.tcl`, or `.def` file
4. The extension will automatically activate and provide language support
The former standalone `NX Tcl Remote Debugger` extension is no longer required. Disable or
uninstall `local-nx.nx-tcl-debug` before using the integrated debugger because both extensions
register the same `nx-tcl` debug type.
## Configuration
The extension can be configured through VS Code settings:
@@ -38,6 +43,49 @@ TCL files default to unlimited inlay hint length so that VS Code does not
truncate later parameter names on a line. An explicit user setting for
`editor.inlayHints.maximumLength` still takes precedence.
## NX Tcl Remote Debugger
### Add a VS Code attach configuration
Create `.vscode/launch.json` through **Run and Debug: create a launch.json file** and select
**NX Tcl: Attach to NX Post**, or use this configuration:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
]
}
```
When VS Code and NX see the source through different roots, set `remoteRoot` to the root used by
NX and keep `localRoot` as the corresponding workspace root.
### Start debugging
1. Set breakpoints on executable Tcl or DEF commands.
2. Start **Attach to NX Post Tcl** in VS Code before running the postprocessor.
3. Start postprocessing in NX.
4. Use Continue, Step Over, Step Into, Step Out, Pause, Variables, Watch, and the Debug Console as
with a normal source debugger.
Breakpoints remain active and stop at every real invocation. Use a hit condition such as `1` for
a one-time stop. Blank lines, comments, declarations, and multiline Tcl commands may be relocated
to the nearest executable command; VS Code shows the resolved line. Dynamic
`LIB_GE_command_buffer_edit_*` bodies are mapped back to their original source and owning Tcl
procedure.
## Usage
Simply open any supported file type and enjoy:
@@ -48,6 +96,7 @@ Simply open any supported file type and enjoy:
- Code formatting (Format Document command)
- Hover information
- Signature help while entering procedure arguments
- Remote NX Tcl debugging with breakpoints and full stepping
## Development and debugging
@@ -56,6 +105,7 @@ Install the root and client dependencies before the first debug session:
```powershell
npm install
npm install --prefix client
npm run test:debugger
```
Use one of the checked-in VS Code launch configurations:
@@ -78,4 +128,5 @@ This extension is actively maintained. For issues or feature requests, please vi
## License
AGPL-3.0 License - see LICENSE file for details.
AGPL-3.0 License - see LICENSE file for details. The embedded NX Tcl Remote Debugger adapter
retains its MIT notice in `debugger/NX_TCL_DEBUGGER_LICENSE.txt`.
+650
View File
@@ -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 };
+68
View File
@@ -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 };
+116
View File
@@ -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 };
+42
View File
@@ -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 }
+3
View File
@@ -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()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NX Tcl Remote Debugger contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nx-post-support",
"version": "2025.9.200",
"version": "2026.8.201",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nx-post-support",
"version": "2025.9.200",
"version": "2026.8.201",
"devDependencies": {
"@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1",
+106 -5
View File
@@ -1,10 +1,16 @@
{
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with syntax highlighting, formatting, linting, and auto-completion for CDL, TCL, and DEF files",
"version": "2026.8.100",
"description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files",
"version": "2026.8.201",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"activationEvents": [
"onLanguage:tcl",
"onLanguage:cdl",
"onLanguage:def",
"onDebug"
],
"extensionDependencies": [
"ms-python.python"
],
@@ -23,15 +29,109 @@
"UDE",
"Postprocessor",
"NX CAM",
"Siemens NX"
"Siemens NX",
"debugger",
"remote debugging"
],
"engines": {
"vscode": "^1.96.0"
},
"categories": [
"Programming Languages"
"Programming Languages",
"Debuggers"
],
"contributes": {
"breakpoints": [
{
"language": "tcl"
},
{
"language": "def"
}
],
"debuggers": [
{
"type": "nx-tcl",
"label": "NX Tcl Remote Debugger",
"languages": [
"tcl",
"def"
],
"configurationAttributes": {
"attach": {
"required": [
"host",
"port"
],
"properties": {
"host": {
"type": "string",
"default": "127.0.0.1",
"description": "Host on which the NX Tcl runtime is listening."
},
"port": {
"type": "number",
"default": 4711,
"description": "TCP port of the NX Tcl runtime."
},
"connectTimeout": {
"type": "number",
"default": 120000,
"description": "How long VS Code retries until NX starts the runtime, in milliseconds."
},
"stopOnEntry": {
"type": "boolean",
"default": false,
"description": "Stop on the first traced Tcl command."
},
"breakOnError": {
"type": "boolean",
"default": true,
"description": "Stop when a traced Tcl command returns TCL_ERROR."
},
"localRoot": {
"type": "string",
"description": "Local source root opened in VS Code."
},
"remoteRoot": {
"type": "string",
"description": "Corresponding source root on the NX host when it differs from localRoot."
}
}
}
},
"initialConfigurations": [
{
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
],
"configurationSnippets": [
{
"label": "NX Tcl: Attach to NX Post",
"description": "Attach VS Code to an NX runtime embedded in NX Post.",
"body": {
"type": "nx-tcl",
"request": "attach",
"name": "Attach to NX Post Tcl",
"host": "127.0.0.1",
"port": 4711,
"connectTimeout": 120000,
"stopOnEntry": false,
"breakOnError": true,
"localRoot": "${workspaceFolder}"
}
}
]
}
],
"languages": [
{
"id": "tcl",
@@ -151,7 +251,8 @@
"compile": "node esbuild.js --production",
"compile:debug": "node esbuild.js",
"watch": "node esbuild.js --watch",
"package": "node esbuild.js --production"
"package": "node esbuild.js --production",
"test:debugger": "node --test test/debugger.test.js"
},
"devDependencies": {
"@types/vscode": "^1.96.0",
+63
View File
@@ -0,0 +1,63 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { PassThrough } = require("node:stream")
const { NxDebugAdapter } = require("../client/src/debugger/debugAdapter")
const manifest = require("../package.json")
function dapMessage(message) {
const json = JSON.stringify(message)
return `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n${json}`
}
function readDapMessage(stream) {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0)
const timer = setTimeout(() => reject(new Error("DAP response timed out")), 2000)
const onData = (chunk) => {
buffer = Buffer.concat([buffer, chunk])
const headerEnd = buffer.indexOf("\r\n\r\n")
if (headerEnd < 0) return
const header = buffer.subarray(0, headerEnd).toString("ascii")
const match = /Content-Length:\s*(\d+)/i.exec(header)
if (!match) return
const length = Number(match[1])
if (buffer.length < headerEnd + 4 + length) return
clearTimeout(timer)
stream.removeListener("data", onData)
resolve(JSON.parse(buffer.subarray(headerEnd + 4, headerEnd + 4 + length)))
}
stream.on("data", onData)
})
}
test("integrated manifest contributes the NX Tcl debugger", () => {
const debuggerContribution = manifest.contributes.debuggers.find(
(entry) => entry.type === "nx-tcl"
)
assert.ok(debuggerContribution)
assert.deepEqual(debuggerContribution.languages, ["tcl", "def"])
assert.deepEqual(
manifest.contributes.breakpoints.map((entry) => entry.language),
["tcl", "def"]
)
})
test("integrated adapter answers a DAP initialize request", async () => {
const input = new PassThrough()
const output = new PassThrough()
const adapter = new NxDebugAdapter(input, output)
const responsePromise = readDapMessage(output)
input.write(
dapMessage({ seq: 1, type: "request", command: "initialize", arguments: {} })
)
const response = await responsePromise
adapter.dispose()
assert.equal(response.success, true)
assert.equal(response.command, "initialize")
assert.equal(response.body.supportsConfigurationDoneRequest, true)
assert.equal(response.body.supportsSetVariable, true)
})