From c3d511688553a4639ea9aaf52c6a9a85d7ba5617 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 28 Aug 2026 22:36:18 +0200 Subject: [PATCH] feat(debugger): integrate NX Tcl Remote Debugger into NX Postprocessor 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 --- .vscodeignore | 6 +- CHANGELOG.md | 6 + README.md | 55 ++- client/src/debugger/debugAdapter.js | 650 +++++++++++++++++++++++++++ client/src/debugger/inlineAdapter.js | 68 +++ client/src/debugger/protocol.js | 116 +++++ client/src/debugger/register.ts | 42 ++ client/src/extension.ts | 3 + debugger/NX_TCL_DEBUGGER_LICENSE.txt | 21 + package-lock.json | 4 +- package.json | 111 ++++- test/debugger.test.js | 63 +++ 12 files changed, 1135 insertions(+), 10 deletions(-) create mode 100644 client/src/debugger/debugAdapter.js create mode 100644 client/src/debugger/inlineAdapter.js create mode 100644 client/src/debugger/protocol.js create mode 100644 client/src/debugger/register.ts create mode 100644 debugger/NX_TCL_DEBUGGER_LICENSE.txt create mode 100644 test/debugger.test.js diff --git a/.vscodeignore b/.vscodeignore index 5c8ca79..3a89f98 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -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 -client/** \ No newline at end of file +server/.claude/** +server/tests/** +client/** +dist/**/*.map diff --git a/CHANGELOG.md b/CHANGELOG.md index be5470d..54d7c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0826605..aa79514 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/client/src/debugger/debugAdapter.js b/client/src/debugger/debugAdapter.js new file mode 100644 index 0000000..94ecc5b --- /dev/null +++ b/client/src/debugger/debugAdapter.js @@ -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 || '', + 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 }; diff --git a/client/src/debugger/inlineAdapter.js b/client/src/debugger/inlineAdapter.js new file mode 100644 index 0000000..7c6c7d4 --- /dev/null +++ b/client/src/debugger/inlineAdapter.js @@ -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 }; diff --git a/client/src/debugger/protocol.js b/client/src/debugger/protocol.js new file mode 100644 index 0000000..2fc2074 --- /dev/null +++ b/client/src/debugger/protocol.js @@ -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 }; diff --git a/client/src/debugger/register.ts b/client/src/debugger/register.ts new file mode 100644 index 0000000..c6d8298 --- /dev/null +++ b/client/src/debugger/register.ts @@ -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 { + 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 } diff --git a/client/src/extension.ts b/client/src/extension.ts index 362751f..bf27936 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -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() diff --git a/debugger/NX_TCL_DEBUGGER_LICENSE.txt b/debugger/NX_TCL_DEBUGGER_LICENSE.txt new file mode 100644 index 0000000..8365b5b --- /dev/null +++ b/debugger/NX_TCL_DEBUGGER_LICENSE.txt @@ -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. diff --git a/package-lock.json b/package-lock.json index 1cea2ae..2678556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5d25631..5ddadc1 100644 --- a/package.json +++ b/package.json @@ -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.200", + "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", diff --git a/test/debugger.test.js b/test/debugger.test.js new file mode 100644 index 0000000..baf2106 --- /dev/null +++ b/test/debugger.test.js @@ -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) +})