abapgit-agent 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,897 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DebugSession — stateful wrapper around ADT debugger REST API
5
+ *
6
+ * All operations use POST /sap/bc/adt/debugger?method=<name>.
7
+ * The session is bound to a specific ABAP work process via:
8
+ * X-sap-adt-sessiontype: stateful
9
+ * Without this header each request may land on a different work process
10
+ * that has no debug state, causing "noSessionAttached" (T100KEY-NO=530).
11
+ *
12
+ * API sequence:
13
+ * 1. POST /sap/bc/adt/debugger/listeners → DEBUGGEE_ID
14
+ * 2. POST /sap/bc/adt/debugger?method=attach&debuggeeId=X → debugSessionId
15
+ * 3. POST /sap/bc/adt/debugger?method=getStack → stack frames
16
+ * 4. POST /sap/bc/adt/debugger?method=getChildVariables → hierarchy (parent→child IDs)
17
+ * 5. POST /sap/bc/adt/debugger?method=getVariables → variable values for leaf IDs
18
+ * 6. POST /sap/bc/adt/debugger?method=stepOver|stepInto|stepReturn|stepContinue
19
+ * 7. POST /sap/bc/adt/debugger?method=terminateDebuggee
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { AdtHttp } = require('./adt-http');
24
+
25
+ // Header required to pin all requests to the same ABAP work process.
26
+ const STATEFUL_HEADER = { 'X-sap-adt-sessiontype': 'stateful' };
27
+
28
+ /**
29
+ * Retry a debug ADT call up to maxRetries times on transient ICM errors.
30
+ *
31
+ * The SAP ICM (load balancer) returns HTTP 400 with an HTML "Service cannot
32
+ * be reached" body when the target ABAP work process is momentarily unavailable
33
+ * (e.g. finishing a previous step, or briefly between requests). This is a
34
+ * transient condition that resolves within a second or two — retrying is safe
35
+ * for all debug read/navigation operations.
36
+ *
37
+ * @param {function} fn - Async function to retry (takes no args)
38
+ * @param {number} maxRetries - Max additional attempts after the first (default 3)
39
+ * @param {number} delayMs - Wait between retries in ms (default 1000)
40
+ */
41
+ async function retryOnIcmError(fn, maxRetries = 3, delayMs = 1000) {
42
+ let lastErr;
43
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
44
+ try {
45
+ return await fn();
46
+ } catch (err) {
47
+ const isIcmError = err && err.statusCode === 400 &&
48
+ err.body && err.body.includes('Service cannot be reached');
49
+ if (!isIcmError) throw err;
50
+ lastErr = err;
51
+ if (attempt < maxRetries) {
52
+ await new Promise(r => setTimeout(r, delayMs));
53
+ }
54
+ }
55
+ }
56
+ throw lastErr;
57
+ }
58
+
59
+ class DebugSession {
60
+ /**
61
+ * @param {AdtHttp} adtHttp - ADT HTTP client instance (carries session cookie)
62
+ * @param {string} sessionId - Active debug session ID (debugSessionId from attach)
63
+ */
64
+ constructor(adtHttp, sessionId) {
65
+ this.http = adtHttp;
66
+ this.sessionId = sessionId;
67
+ }
68
+
69
+ /**
70
+ * Attach to a paused ABAP work process.
71
+ * Must be called after the listener returns a DEBUGGEE_ID.
72
+ *
73
+ * @param {string} debuggeeId - Value of <DEBUGGEE_ID> from listener response
74
+ * @param {string} requestUser - SAP logon user (uppercase)
75
+ * @returns {Promise<string>} The debugSessionId to use for all subsequent calls
76
+ */
77
+ async attach(debuggeeId, requestUser) {
78
+ const url = `/sap/bc/adt/debugger?method=attach` +
79
+ `&debuggeeId=${encodeURIComponent(debuggeeId)}` +
80
+ `&dynproDebugging=true` +
81
+ `&debuggingMode=user` +
82
+ `&requestUser=${encodeURIComponent(requestUser)}`;
83
+
84
+ const { body } = await this.http.post(url, '', {
85
+ contentType: 'application/vnd.sap.as+xml',
86
+ headers: STATEFUL_HEADER
87
+ });
88
+
89
+ // Response: <dbg:attach debugSessionId="..." isSteppingPossible="true" ...>
90
+ const debugSessionId =
91
+ AdtHttp.extractXmlAttr(body, 'dbg:attach', 'debugSessionId') ||
92
+ (body.match(/debugSessionId="([^"]+)"/) || [])[1];
93
+
94
+ if (debugSessionId) {
95
+ this.sessionId = debugSessionId;
96
+ }
97
+
98
+ return this.sessionId;
99
+ }
100
+
101
+ /**
102
+ * Execute a step action.
103
+ * @param {'stepInto'|'stepOver'|'stepOut'|'stepReturn'|'continue'|'stepContinue'} type
104
+ * @returns {Promise<{ position: object, source: string[] }>}
105
+ */
106
+ async step(type = 'stepOver') {
107
+ // Map user-friendly names to ADT method names
108
+ const methodMap = {
109
+ stepInto: 'stepInto',
110
+ stepOver: 'stepOver',
111
+ stepOut: 'stepReturn', // ADT uses stepReturn, not stepOut
112
+ stepReturn: 'stepReturn',
113
+ continue: 'stepContinue', // ADT uses stepContinue, not continue
114
+ stepContinue: 'stepContinue'
115
+ };
116
+
117
+ const validTypes = Object.keys(methodMap);
118
+ if (!validTypes.includes(type)) {
119
+ throw new Error(`Invalid step type: ${type}. Use one of: ${validTypes.join(', ')}`);
120
+ }
121
+
122
+ const method = methodMap[type];
123
+
124
+ // stepContinue resumes the debuggee — it runs to the next breakpoint or to
125
+ // completion. When the program runs to completion ADT returns HTTP 500
126
+ // (no suspended session left). Treat both 200 and 500 as "continued".
127
+ if (method === 'stepContinue') {
128
+ return retryOnIcmError(async () => {
129
+ try {
130
+ await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
131
+ contentType: 'application/vnd.sap.as+xml',
132
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
133
+ });
134
+ // 200: program hit another breakpoint (or is still running).
135
+ // Position query is not meaningful until a new breakpoint fires via
136
+ // the listener, so return the sentinel and let the caller re-attach.
137
+ return { position: { continued: true }, source: [] };
138
+ } catch (err) {
139
+ // 500: debuggee ran to completion, session ended normally.
140
+ if (err && err.statusCode === 500) {
141
+ return { position: { continued: true, finished: true }, source: [] };
142
+ }
143
+ throw err;
144
+ }
145
+ });
146
+ }
147
+
148
+ return retryOnIcmError(async () => {
149
+ await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
150
+ contentType: 'application/vnd.sap.as+xml',
151
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
152
+ });
153
+ return this.getPosition();
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Retrieve all (or named) variables at the current stack frame.
159
+ *
160
+ * Two-step protocol (from abap-adt-api v7):
161
+ * 1. getChildVariables — body requests children of @ROOT, @PARAMETERS, @LOCALS,
162
+ * and @DATAAGING in a single call. Content-Type/Accept must include
163
+ * dataname=com.sap.adt.debugger.ChildVariables (routing key for ADT handler).
164
+ * The response HIERARCHIES table maps parent→child IDs.
165
+ * Requesting @PARAMETERS and @LOCALS directly is required to get their
166
+ * leaf children (the actual variable IDs) in the same response.
167
+ * 2. getVariables — body uses STPDA_ADT_VARIABLE with the leaf CHILD_IDs
168
+ * extracted from HIERARCHIES (children whose parent is @PARAMETERS or
169
+ * @LOCALS, not the group names themselves).
170
+ * Content-Type/Accept must include dataname=com.sap.adt.debugger.Variables.
171
+ *
172
+ * @param {string|null} name - Optional variable name filter (case-insensitive)
173
+ * @returns {Promise<Array<{ name: string, type: string, value: string }>>}
174
+ */
175
+ async getVariables(name = null) {
176
+ const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
177
+ const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
178
+
179
+ // ── Step 1: getChildVariables ──────────────────────────────────────────
180
+ // Request the hierarchy for all scopes in one call:
181
+ // @ROOT → discovers group nodes: @PARAMETERS, @LOCALS, ME
182
+ // @PARAMETERS → actual import/export parameter variable IDs
183
+ // @LOCALS → actual local variable IDs
184
+ // @DATAAGING → internal data aging variables
185
+ // Including @PARAMETERS and @LOCALS here is essential — ADT expands their
186
+ // children in the same response so we get the leaf IDs in a single round-trip.
187
+ const childBody =
188
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
189
+ `<asx:abap version="1.0" xmlns:asx="http://www.sap.com/abapxml">` +
190
+ `<asx:values><DATA><HIERARCHIES>` +
191
+ `<STPDA_ADT_VARIABLE_HIERARCHY><PARENT_ID>@ROOT</PARENT_ID></STPDA_ADT_VARIABLE_HIERARCHY>` +
192
+ `<STPDA_ADT_VARIABLE_HIERARCHY><PARENT_ID>@PARAMETERS</PARENT_ID></STPDA_ADT_VARIABLE_HIERARCHY>` +
193
+ `<STPDA_ADT_VARIABLE_HIERARCHY><PARENT_ID>@LOCALS</PARENT_ID></STPDA_ADT_VARIABLE_HIERARCHY>` +
194
+ `<STPDA_ADT_VARIABLE_HIERARCHY><PARENT_ID>@DATAAGING</PARENT_ID></STPDA_ADT_VARIABLE_HIERARCHY>` +
195
+ `</HIERARCHIES></DATA></asx:values></asx:abap>`;
196
+
197
+ let childXml = '';
198
+ try {
199
+ const resp = await this.http.post(
200
+ '/sap/bc/adt/debugger?method=getChildVariables', childBody, {
201
+ contentType: CT_CHILD,
202
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_CHILD }
203
+ }
204
+ );
205
+ childXml = resp.body || '';
206
+ } catch (e) {
207
+ return [];
208
+ }
209
+
210
+ // Extract the child IDs whose parent is @PARAMETERS or @LOCALS.
211
+ // These are the opaque IDs used in the next call — not human-readable names.
212
+ const childIds = extractChildIds(childXml, ['@PARAMETERS', '@LOCALS', '@ROOT', '@DATAAGING']);
213
+
214
+ if (childIds.length === 0) return [];
215
+
216
+ // ── Step 2: getVariables — get values for those IDs ────────────────────
217
+ const varBody =
218
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
219
+ `<asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">` +
220
+ `<asx:values><DATA>` +
221
+ childIds.map(id => `<STPDA_ADT_VARIABLE><ID>${id}</ID></STPDA_ADT_VARIABLE>`).join('') +
222
+ `</DATA></asx:values></asx:abap>`;
223
+
224
+ let varsXml = '';
225
+ try {
226
+ const resp = await this.http.post(
227
+ '/sap/bc/adt/debugger?method=getVariables', varBody, {
228
+ contentType: CT_VARS,
229
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_VARS }
230
+ }
231
+ );
232
+ varsXml = resp.body || '';
233
+ } catch (e) {
234
+ // Fall back to whatever step 1 returned
235
+ varsXml = childXml;
236
+ }
237
+
238
+ const variables = parseVariables(varsXml);
239
+ if (name) {
240
+ return variables.filter(v => v.name.toUpperCase() === name.toUpperCase());
241
+ }
242
+ return variables;
243
+ }
244
+
245
+ /**
246
+ * Drill one level into a complex variable (internal table rows, structure fields).
247
+ *
248
+ * For structures: uses getChildVariables with parentId — returns field child IDs.
249
+ * For internal tables: uses getChildVariables with TABLE_FROM/TABLE_TO range because
250
+ * ADT does not populate HIERARCHY entries for tables without an explicit range.
251
+ *
252
+ * @param {string} parentId - Opaque variable ID returned in the `id` field of getVariables()
253
+ * @param {object} [meta] - Optional metadata from the parent variable: { metaType, tableLines }
254
+ * @returns {Promise<Array<{ id: string, name: string, type: string, value: string }>>}
255
+ */
256
+ async getVariableChildren(parentId, meta = {}) {
257
+ const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
258
+ const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
259
+
260
+ const isTable = meta.metaType === 'table';
261
+ const tableLines = meta.tableLines || 0;
262
+
263
+ // ── Data reference (TYPE REF TO): dereference with parentId->* ───────
264
+ // ADT represents the pointed-to object as a single virtual variable whose
265
+ // ID is "<parentId>->*". Fetching it with getVariables returns the
266
+ // dereferenced structure/scalar. If that variable is itself a structure,
267
+ // the caller can expand it again.
268
+ // Example: LR_REQUEST (meta=dataref) → LR_REQUEST->* (the TY_UNIT_PARAMS struct)
269
+ if (meta.metaType === 'dataref') {
270
+ const derefId = `${parentId}->*`;
271
+ const varBody =
272
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
273
+ `<asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">` +
274
+ `<asx:values><DATA>` +
275
+ `<STPDA_ADT_VARIABLE><ID>${derefId}</ID></STPDA_ADT_VARIABLE>` +
276
+ `</DATA></asx:values></asx:abap>`;
277
+
278
+ try {
279
+ const resp = await this.http.post(
280
+ '/sap/bc/adt/debugger?method=getVariables', varBody, {
281
+ contentType: CT_VARS,
282
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_VARS }
283
+ }
284
+ );
285
+ const derefVars = parseVariables(resp.body || '');
286
+ // If the dereference returned a structure, expand one level into it
287
+ if (derefVars.length === 1 && derefVars[0].metaType === 'structure') {
288
+ return this.getVariableChildren(derefVars[0].id || derefId, { metaType: 'structure' });
289
+ }
290
+ return derefVars;
291
+ } catch (e) {
292
+ return [];
293
+ }
294
+ }
295
+
296
+ // ── Internal table: rows use ID format parentId[N] ────────────────────
297
+ // ADT getChildVariables does not return row hierarchy entries for tables
298
+ // (even with TABLE_FROM/TABLE_TO in the request body).
299
+ // Instead, rows can be fetched directly with getVariables using IDs of
300
+ // the form LT_PARTS[1], LT_PARTS[2], ... LT_PARTS[N].
301
+ if (isTable && tableLines > 0) {
302
+ const limit = Math.min(tableLines, 100);
303
+ const rowIds = [];
304
+ for (let i = 1; i <= limit; i++) rowIds.push(`${parentId}[${i}]`);
305
+
306
+ const varBody =
307
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
308
+ `<asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">` +
309
+ `<asx:values><DATA>` +
310
+ rowIds.map(id => `<STPDA_ADT_VARIABLE><ID>${id}</ID></STPDA_ADT_VARIABLE>`).join('') +
311
+ `</DATA></asx:values></asx:abap>`;
312
+
313
+ try {
314
+ const resp = await this.http.post(
315
+ '/sap/bc/adt/debugger?method=getVariables', varBody, {
316
+ contentType: CT_VARS,
317
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_VARS }
318
+ }
319
+ );
320
+ return parseVariables(resp.body || '');
321
+ } catch (e) {
322
+ return [];
323
+ }
324
+ }
325
+
326
+ // ── Structure / object reference: use getChildVariables ───────────────
327
+ const childBody =
328
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
329
+ `<asx:abap version="1.0" xmlns:asx="http://www.sap.com/abapxml">` +
330
+ `<asx:values><DATA><HIERARCHIES>` +
331
+ `<STPDA_ADT_VARIABLE_HIERARCHY><PARENT_ID>${parentId}</PARENT_ID></STPDA_ADT_VARIABLE_HIERARCHY>` +
332
+ `</HIERARCHIES></DATA></asx:values></asx:abap>`;
333
+
334
+ let childXml = '';
335
+ try {
336
+ const resp = await this.http.post(
337
+ '/sap/bc/adt/debugger?method=getChildVariables', childBody, {
338
+ contentType: CT_CHILD,
339
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_CHILD }
340
+ }
341
+ );
342
+ childXml = resp.body || '';
343
+ } catch (e) {
344
+ return [];
345
+ }
346
+
347
+ const childIds = extractChildIds(childXml, [parentId]);
348
+ if (childIds.length === 0) return [];
349
+
350
+ const varBody =
351
+ `<?xml version="1.0" encoding="UTF-8" ?>` +
352
+ `<asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">` +
353
+ `<asx:values><DATA>` +
354
+ childIds.map(id => `<STPDA_ADT_VARIABLE><ID>${id}</ID></STPDA_ADT_VARIABLE>`).join('') +
355
+ `</DATA></asx:values></asx:abap>`;
356
+
357
+ try {
358
+ const resp = await this.http.post(
359
+ '/sap/bc/adt/debugger?method=getVariables', varBody, {
360
+ contentType: CT_VARS,
361
+ headers: { ...STATEFUL_HEADER, 'Accept': CT_VARS }
362
+ }
363
+ );
364
+ return parseVariables(resp.body || '');
365
+ } catch (e) {
366
+ return [];
367
+ }
368
+ }
369
+
370
+ /**
371
+ * @returns {Promise<Array<{ frame: number, class: string, method: string, line: number }>>}
372
+ */
373
+ async getStack() {
374
+ return retryOnIcmError(async () => {
375
+ // Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
376
+ try {
377
+ const { body } = await this.http.get(
378
+ '/sap/bc/adt/debugger/stack?emode=_&semanticURIs=true', {
379
+ accept: 'application/xml',
380
+ headers: STATEFUL_HEADER
381
+ }
382
+ );
383
+ const frames = parseStack(body);
384
+ if (frames.length > 0) return frames;
385
+ } catch (e) {
386
+ // Re-throw ICM errors so the outer retryOnIcmError can catch them
387
+ if (e && e.statusCode === 400 && e.body && e.body.includes('Service cannot be reached')) throw e;
388
+ // Otherwise fall through to POST approach
389
+ }
390
+ // Fallback: POST approach (older ADT versions)
391
+ const { body } = await this.http.post(
392
+ '/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
393
+ contentType: 'application/vnd.sap.as+xml',
394
+ headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
395
+ }
396
+ );
397
+ return parseStack(body);
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Get the current execution position (top frame of call stack).
403
+ * Also fetches source lines around the current line.
404
+ * @returns {Promise<{ position: object, source: string[] }>}
405
+ */
406
+ async getPosition() {
407
+ const frames = await this.getStack();
408
+ const position = frames[0] || {};
409
+ let source = [];
410
+
411
+ if (position.line) {
412
+ try {
413
+ source = await this.getSource(position);
414
+ } catch (e) {
415
+ // Source fetch is best-effort
416
+ }
417
+ }
418
+
419
+ return { position, source };
420
+ }
421
+
422
+ /**
423
+ * Fetch source lines around the current line for a given stack frame.
424
+ *
425
+ * Strategy:
426
+ * 1. Try ADT via the adtUri embedded in the stack frame
427
+ * (e.g. /sap/bc/adt/oo/classes/zcl_foo/source/main)
428
+ * 2. Fall back to local file system — search for a matching .abap file
429
+ * in the current working directory tree.
430
+ *
431
+ * @param {object} frame - Stack frame from parseStack() (must have .line, optionally .adtUri, .program)
432
+ * @param {number} [context=5] - Lines of context above and below current line
433
+ * @returns {Promise<Array<{ lineNumber: number, text: string, current: boolean }>>}
434
+ */
435
+ async getSource(frame, context = 5) {
436
+ const line = frame.line || 1;
437
+ const start = Math.max(1, line - context);
438
+ const end = line + context;
439
+
440
+ // ── 1. Try ADT source via adtUri ──────────────────────────────────────
441
+ if (frame.adtUri) {
442
+ // adtUri may contain a fragment (#start=25,0) — strip it
443
+ const adtPath = frame.adtUri.split('#')[0];
444
+ try {
445
+ const { body } = await this.http.get(
446
+ `${adtPath}?start=${start}&end=${end}`,
447
+ { accept: 'text/plain' }
448
+ );
449
+ if (body && body.trim()) {
450
+ const allLines = parseSourceLines(body, 1, line);
451
+ // ADT may ignore range params and return the full file — slice to window
452
+ if (allLines.length > (end - start + 2)) {
453
+ return allLines.slice(start - 1, end);
454
+ }
455
+ return allLines;
456
+ }
457
+ } catch (e) {
458
+ // Fall through to local file
459
+ }
460
+ }
461
+
462
+ // ── 2. Try local file ─────────────────────────────────────────────────
463
+ const localFile = resolveLocalFile(frame.program || '');
464
+ if (localFile) {
465
+ try {
466
+ const content = fs.readFileSync(localFile, 'utf8');
467
+ const lines = content.split('\n');
468
+ const result = [];
469
+ for (let n = start; n <= Math.min(end, lines.length); n++) {
470
+ result.push({ lineNumber: n, text: lines[n - 1] || '', current: n === line });
471
+ }
472
+ return result;
473
+ } catch (e) {
474
+ // File not readable
475
+ }
476
+ }
477
+
478
+ return [];
479
+ }
480
+
481
+ /**
482
+ * Expand a variable along a `->` separated path.
483
+ *
484
+ * Allows users to drill through nested object/structure/table levels in one
485
+ * command: `x LO_FACTORY->MT_COMMAND_MAP`
486
+ *
487
+ * Algorithm:
488
+ * 1. Start with all top-level variables.
489
+ * 2. For each path segment after the first:
490
+ * a. Find the parent variable by matching the previous segment name
491
+ * b. Expand it to get its children
492
+ * c. Find the child matching the next segment name
493
+ * 3. Expand the final target and return its children.
494
+ *
495
+ * @param {string[]} pathParts - e.g. ['LO_FACTORY', 'MT_COMMAND_MAP']
496
+ * @returns {Promise<{ variable: object, children: object[] }>}
497
+ * variable — the resolved target variable (at the end of the path)
498
+ * children — the expanded children of that target
499
+ */
500
+ async expandPath(pathParts) {
501
+ if (pathParts.length === 0) throw new Error('Empty path');
502
+
503
+ // Resolve root variable from top-level vars
504
+ let vars = await this.getVariables();
505
+ let current = vars.find(v => v.name.toUpperCase() === pathParts[0].toUpperCase());
506
+ if (!current) {
507
+ throw new Error(`Variable '${pathParts[0]}' not found. Run 'v' to list variables.`);
508
+ }
509
+
510
+ // Walk intermediate path segments
511
+ for (let i = 1; i < pathParts.length; i++) {
512
+ if (!current.id) {
513
+ throw new Error(`Variable '${current.name}' has no ADT ID — cannot expand.`);
514
+ }
515
+ const meta = { metaType: current.metaType || '', tableLines: current.tableLines || 0 };
516
+ const children = await this.getVariableChildren(current.id, meta);
517
+ const segment = pathParts[i].toUpperCase();
518
+
519
+ // * — explicit dereference marker (ABAP/ADT convention: TYPE REF TO -> *)
520
+ // getVariableChildren already auto-derefs datarefs, so * is a no-op here.
521
+ // It is accepted to allow: x lr_request->* and x lr_request->*->files
522
+ if (segment === '*') continue;
523
+
524
+ // Pattern 1: FIELD[N] — find FIELD in children, expand it, take row N
525
+ // Supports: x LO_FACTORY->MT_COMMAND_MAP[1]
526
+ const fieldRowMatch = segment.match(/^([A-Z0-9_@]+)\[(\d+)\]$/);
527
+ if (fieldRowMatch) {
528
+ const fieldName = fieldRowMatch[1];
529
+ const rowIndex = parseInt(fieldRowMatch[2], 10);
530
+ const fieldVar = children.find(c => c.name.toUpperCase() === fieldName);
531
+ if (!fieldVar) {
532
+ const avail = children.map(c => c.name).join(', ') || '(none)';
533
+ throw new Error(`'${fieldName}' not found in '${current.name}'. Available: ${avail}`);
534
+ }
535
+ if (!fieldVar.id) {
536
+ throw new Error(`'${fieldVar.name}' has no ADT ID — cannot expand.`);
537
+ }
538
+ const rowMeta = { metaType: fieldVar.metaType || '', tableLines: fieldVar.tableLines || 0 };
539
+ const rows = await this.getVariableChildren(fieldVar.id, rowMeta);
540
+ const rowTarget = rows.find(r => r.name === `[${rowIndex}]`);
541
+ if (!rowTarget) {
542
+ throw new Error(
543
+ `Row [${rowIndex}] not found in '${fieldName}'. Table has ${rows.length} rows.`
544
+ );
545
+ }
546
+ current = rowTarget;
547
+ continue;
548
+ }
549
+
550
+ // Pattern 2: [N] — take row N from current (a table expanded in previous step)
551
+ // Supports: x LO_FACTORY->MT_COMMAND_MAP->[1]
552
+ const rowOnlyMatch = segment.match(/^\[(\d+)\]$/);
553
+ if (rowOnlyMatch) {
554
+ const rowTarget = children.find(r => r.name === `[${rowOnlyMatch[1]}]`);
555
+ if (!rowTarget) {
556
+ throw new Error(
557
+ `Row [${rowOnlyMatch[1]}] not found. Table has ${children.length} rows.`
558
+ );
559
+ }
560
+ current = rowTarget;
561
+ continue;
562
+ }
563
+
564
+ // Pattern 3: plain field name
565
+ const next = children.find(c => c.name.toUpperCase() === segment);
566
+ if (!next) {
567
+ const names = children.map(c => c.name).join(', ') || '(none)';
568
+ throw new Error(
569
+ `'${pathParts[i]}' not found in '${current.name}'. Available: ${names}`
570
+ );
571
+ }
572
+ current = next;
573
+ }
574
+
575
+ if (!current.id) {
576
+ throw new Error(`Variable '${current.name}' has no ADT ID — cannot expand.`);
577
+ }
578
+
579
+ const meta = { metaType: current.metaType || '', tableLines: current.tableLines || 0 };
580
+ const children = await this.getVariableChildren(current.id, meta);
581
+ return { variable: current, children };
582
+ }
583
+
584
+ /**
585
+ * Terminate the debug session.
586
+ */
587
+ async terminate() {
588
+ await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
589
+ contentType: 'application/vnd.sap.as+xml',
590
+ headers: STATEFUL_HEADER
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Detach from the debuggee without killing it.
596
+ * Issues a stepContinue so the ABAP program resumes running.
597
+ *
598
+ * stepContinue is a long-poll in ADT — it only responds when the program
599
+ * hits another breakpoint (200) or finishes (500), which may be never.
600
+ * We use postFire() which resolves as soon as the request bytes are
601
+ * flushed to the TCP send buffer — no need to wait for a response.
602
+ * The existing session cookies are used so ADT recognises the request.
603
+ */
604
+ async detach() {
605
+ try {
606
+ await this.http.postFire('/sap/bc/adt/debugger?method=stepContinue', '', {
607
+ contentType: 'application/vnd.sap.as+xml',
608
+ headers: STATEFUL_HEADER
609
+ });
610
+ } catch (e) {
611
+ // Ignore — fire-and-forget; errors here mean the session already closed.
612
+ }
613
+ }
614
+ }
615
+
616
+ // ─── XML parsers ─────────────────────────────────────────────────────────────
617
+
618
+ /**
619
+ * Extract a human-readable name from an opaque ADT object-reference child ID.
620
+ *
621
+ * ADT returns child variables of object references with opaque IDs as their
622
+ * NAME field, e.g.:
623
+ * {O:73*\CLASS=ZCL_ABGAGT_CMD_FACTORY}-MT_COMMAND_MAP\TYPE=%_T000...
624
+ * {O:73*\CLASS=ZCL_ABGAGT_CMD_FACTORY}-MT_COMMAND_MAP[1]
625
+ *
626
+ * Patterns handled (in priority order):
627
+ * {O:N*\...}-FIELDNAME[N] → [N] (table row)
628
+ * {O:N*\...}-FIELDNAME\TYPE=... → FIELDNAME (object attribute)
629
+ * {O:N*\...}-FIELDNAME → FIELDNAME (object attribute, no type suffix)
630
+ *
631
+ * Returns null when the input is already a friendly name (no opaque prefix).
632
+ *
633
+ * @param {string} rawName
634
+ * @returns {string|null}
635
+ */
636
+ function extractFriendlyName(rawName) {
637
+ if (!rawName) return null;
638
+
639
+ if (rawName.startsWith('{')) {
640
+ // Structure field inside a table row: {O:73*\...}-TABLE[N]-FIELDNAME → FIELDNAME
641
+ // This must be checked first (most specific pattern).
642
+ const rowFieldMatch = rawName.match(/\}-[A-Z0-9_@~]+\[\d+\]-([A-Z0-9_@~]+)(?:\\TYPE=|$)/i);
643
+ if (rowFieldMatch) return rowFieldMatch[1].toUpperCase();
644
+ // Table row suffix: {O:73*\...}-TABLE[N] → [N]
645
+ const rowMatch = rawName.match(/\}-[A-Z0-9_@~]+(\[\d+\])\s*$/i);
646
+ if (rowMatch) return rowMatch[1];
647
+ // Object attribute: {O:73*\...}-IF_FOO~FIELDNAME or -FIELDNAME → IF_FOO~FIELDNAME / FIELDNAME
648
+ // ~ is the ABAP interface attribute separator; keep it so interface-redefined constants
649
+ // remain distinguishable (e.g. IF_HTTP_ENTITY~FORMFIELD_ENCODING vs IF_HTTP_REQUEST~...).
650
+ const attrMatch = rawName.match(/\}-([A-Z0-9_@~]+)(?:\\TYPE=|$)/i);
651
+ return attrMatch ? attrMatch[1].toUpperCase() : null;
652
+ }
653
+
654
+ // Dereference field access: VARNAME->FIELDNAME or VARNAME->FIELD[N] → FIELD / [N]
655
+ // This covers children of dereferenced data references, e.g.
656
+ // LR_REQUEST->PACKAGE → PACKAGE
657
+ // LR_REQUEST->FILES → FILES
658
+ // LR_REQUEST->FILES[1] → [1] (table row within dereferenced field)
659
+ // The last ->FIELDNAME or ->FIELD[N] segment is used.
660
+ // Exclude ->* (the dereference marker itself, not a field name).
661
+ if (rawName.includes('->')) {
662
+ // Table row: VARNAME->FIELD[N] → [N] (more specific — check first)
663
+ const derefRowMatch = rawName.match(/->([A-Z0-9_@]+)(\[\d+\])\s*$/i);
664
+ if (derefRowMatch) return derefRowMatch[2];
665
+ // Plain field: VARNAME->FIELDNAME → FIELDNAME
666
+ const derefFieldMatch = rawName.match(/->([A-Z0-9_@]+)(?:\\TYPE=|\s*$)/i);
667
+ if (derefFieldMatch && derefFieldMatch[1] !== '*') return derefFieldMatch[1].toUpperCase();
668
+ }
669
+
670
+ return null;
671
+ }
672
+
673
+ /**
674
+ * Clean an ADT type string for human display.
675
+ *
676
+ * ADT returns several non-friendly type formats for object/table children:
677
+ * \TYPE=%_T00004S00000130... — generated internal type ID (starts with %) → return ''
678
+ * \TYPE=TY_UNIT_PARAMS — named type → return 'TY_UNIT_PARAMS'
679
+ * Type TY_COMMAND_MAP in ZCL_... — verbose format → return 'TY_COMMAND_MAP'
680
+ *
681
+ * @param {string} rawType
682
+ * @returns {string}
683
+ */
684
+ function cleanTypeStr(rawType) {
685
+ if (!rawType) return '';
686
+ if (rawType.startsWith('\\TYPE=')) {
687
+ const inner = rawType.slice(6); // strip the \TYPE= prefix
688
+ // Internal generated type IDs start with %, = or are empty — discard them
689
+ if (!inner || inner.startsWith('%') || inner.startsWith('=') || inner.startsWith(' ')) return '';
690
+ return inner; // named type like TY_UNIT_PARAMS — keep it
691
+ }
692
+ // "Type TY_COMMAND_MAP in ZCL_ABGAGT_CMD_FACTORY" → "TY_COMMAND_MAP"
693
+ const m = rawType.match(/^Type\s+(\S+)/i);
694
+ if (m) return m[1];
695
+ return rawType;
696
+ }
697
+
698
+ /**
699
+ * Decode XML character entity references in a string.
700
+ * ADT XML-encodes variable names, types, and values — e.g. field symbol names
701
+ * appear as &lt;LS_REQUEST&gt; instead of <LS_REQUEST>.
702
+ *
703
+ * @param {string} s
704
+ * @returns {string}
705
+ */
706
+ function decodeXmlEntities(s) {
707
+ if (!s || !s.includes('&')) return s;
708
+ return s
709
+ .replace(/&lt;/g, '<')
710
+ .replace(/&gt;/g, '>')
711
+ .replace(/&amp;/g, '&')
712
+ .replace(/&quot;/g, '"')
713
+ .replace(/&apos;/g, "'");
714
+ }
715
+
716
+ /**
717
+ * Parse ADT variables response XML into a flat array.
718
+ * Handles the actual SAP ADT format (STPDA_ADT_VARIABLE elements) and
719
+ * the legacy namespace-prefixed format (adtdbg:variable or dbg:variable).
720
+ *
721
+ * @param {string} xml
722
+ * @returns {Array<{ name: string, type: string, value: string }>}
723
+ */
724
+ function parseVariables(xml) {
725
+ const variables = [];
726
+
727
+ // Try modern SAP ADT format: <STPDA_ADT_VARIABLE> elements
728
+ const stpdaRe = /<STPDA_ADT_VARIABLE>([\s\S]*?)<\/STPDA_ADT_VARIABLE>/gi;
729
+ let m;
730
+ while ((m = stpdaRe.exec(xml)) !== null) {
731
+ const inner = m[1];
732
+ const id = AdtHttp.extractXmlAttr(inner, 'ID', null) || '';
733
+ const rawName = decodeXmlEntities(AdtHttp.extractXmlAttr(inner, 'NAME', null) || '');
734
+ const name = extractFriendlyName(rawName) || rawName;
735
+ const type = cleanTypeStr(decodeXmlEntities(AdtHttp.extractXmlAttr(inner, 'DECLARED_TYPE_NAME', null) ||
736
+ AdtHttp.extractXmlAttr(inner, 'ACTUAL_TYPE_NAME', null) || ''));
737
+ const value = decodeXmlEntities(AdtHttp.extractXmlAttr(inner, 'VALUE', null) || '');
738
+ const metaType = AdtHttp.extractXmlAttr(inner, 'META_TYPE', null) || '';
739
+ const tableLines = parseInt(AdtHttp.extractXmlAttr(inner, 'TABLE_LINES', null) || '0', 10);
740
+ if (name) variables.push({ id, name, type, value, metaType, tableLines });
741
+ }
742
+
743
+ if (variables.length > 0) return variables;
744
+
745
+ // Fallback: legacy namespace-prefixed format
746
+ const entryRe = /<(?:adtdbg:|dbg:)?variable([^>]*)>([\s\S]*?)<\/(?:adtdbg:|dbg:)?variable>/gi;
747
+ let entryMatch;
748
+ while ((entryMatch = entryRe.exec(xml)) !== null) {
749
+ const attrs = entryMatch[1];
750
+ const inner = entryMatch[2];
751
+ const name = decodeXmlEntities((attrs.match(/(?:dbg:|adtdbg:)?name="([^"]*)"/) || [])[1] ||
752
+ AdtHttp.extractXmlAttr(inner, 'name', null) || '');
753
+ const type = decodeXmlEntities((attrs.match(/(?:dbg:|adtdbg:)?type="([^"]*)"/) || [])[1] ||
754
+ AdtHttp.extractXmlAttr(inner, 'type', null) || '');
755
+ const value = decodeXmlEntities((attrs.match(/(?:dbg:|adtdbg:)?value="([^"]*)"/) || [])[1] ||
756
+ AdtHttp.extractXmlAttr(inner, 'value', null) || '');
757
+ if (name) variables.push({ name, type, value });
758
+ }
759
+
760
+ return variables;
761
+ }
762
+
763
+ /**
764
+ * Parse ADT stack response XML into a flat array of frames.
765
+ * The response uses <stackEntry> elements with plain attributes:
766
+ * stackPosition, programName, includeName, line, eventName, stackType
767
+ * @param {string} xml
768
+ * @returns {Array<{ frame: number, class: string, method: string, include: string, line: number, program: string }>}
769
+ */
770
+ function parseStack(xml) {
771
+ const frames = [];
772
+
773
+ // Match self-closing <stackEntry .../> or wrapped <stackEntry ...></stackEntry>
774
+ const entryRe = /<stackEntry([^>]*?)(?:\/>|>[\s\S]*?<\/stackEntry>)/gi;
775
+ let m;
776
+
777
+ while ((m = entryRe.exec(xml)) !== null) {
778
+ const attrs = m[1];
779
+
780
+ const stackType = (attrs.match(/\bstackType="([^"]*)"/) || [])[1] || '';
781
+ // Skip DYNP (screen) frames — only ABAP frames are useful
782
+ if (stackType && stackType !== 'ABAP') continue;
783
+
784
+ const frameNum = parseInt((attrs.match(/\bstackPosition="([^"]*)"/) || [])[1] || '0', 10);
785
+ const cls = (attrs.match(/\bprogramName="([^"]*)"/) || [])[1] || '';
786
+ const method = (attrs.match(/\beventName="([^"]*)"/) || [])[1] || '';
787
+ const include = (attrs.match(/\bincludeName="([^"]*)"/) || [])[1] || '';
788
+ const lineStr = (attrs.match(/\bline="([^"]*)"/) || [])[1] || '0';
789
+ const line = parseInt(lineStr, 10);
790
+ const isActive = (attrs.match(/\bisActive="([^"]*)"/) || [])[1] === 'true';
791
+ // adtcore:uri="/sap/bc/adt/oo/classes/zcl_foo/source/main#start=25,0"
792
+ const adtUri = (attrs.match(/adtcore:uri="([^"]*)"/) || [])[1] || null;
793
+
794
+ frames.push({ frame: frameNum, class: cls, method, include, line, program: cls, isActive, adtUri });
795
+ }
796
+
797
+ // Sort descending by stackPosition so frame[0] = top of stack (highest position)
798
+ frames.sort((a, b) => b.frame - a.frame);
799
+ return frames;
800
+ }
801
+
802
+ // ─── Source helpers ──────────────────────────────────────────────────────────
803
+
804
+ /**
805
+ * Convert a raw ADT source body string into the structured line array.
806
+ * @param {string} body - Raw text from ADT (may have leading/trailing whitespace)
807
+ * @param {number} start - Line number of the first line in body
808
+ * @param {number} current - The currently executing line number
809
+ */
810
+ function parseSourceLines(body, start, current) {
811
+ return body.split('\n').map((text, i) => ({
812
+ lineNumber: start + i,
813
+ text,
814
+ current: (start + i) === current
815
+ }));
816
+ }
817
+
818
+ /**
819
+ * Try to find a local .abap file matching the ABAP program/class name.
820
+ *
821
+ * The programName from the stack is in the form:
822
+ * ZCL_ABGAGT_UTIL===============CP (class main pool)
823
+ * SAPLHTTP_RUNTIME (program)
824
+ *
825
+ * We extract the base object name, lowercase it, and glob for matching
826
+ * .abap files under the current working directory.
827
+ *
828
+ * @param {string} programName - ABAP program name (with optional padding/suffix)
829
+ * @returns {string|null} Absolute path to the local file, or null if not found
830
+ */
831
+ function resolveLocalFile(programName) {
832
+ if (!programName) return null;
833
+
834
+ // Strip padding characters and trailing include suffix (CP, CU, etc.)
835
+ const base = programName
836
+ .replace(/=+\w*$/, '') // remove trailing ===...CP, ===...CM009 etc.
837
+ .replace(/=+$/, '') // remove any remaining padding
838
+ .trim();
839
+
840
+ if (!base) return null;
841
+
842
+ const baseLower = base.toLowerCase();
843
+ const cwd = process.cwd();
844
+
845
+ // Candidate patterns in priority order: class main, locals, program, interface
846
+ const patterns = [
847
+ `${baseLower}.clas.abap`,
848
+ `${baseLower}.clas.locals_imp.abap`,
849
+ `${baseLower}.prog.abap`,
850
+ `${baseLower}.intf.abap`,
851
+ ];
852
+
853
+ // Search recursively under common source dirs, then cwd
854
+ const searchDirs = [];
855
+ for (const dir of ['abap', 'src', '.']) {
856
+ const d = path.join(cwd, dir);
857
+ try { if (fs.statSync(d).isDirectory()) searchDirs.push(d); } catch (e) { /* skip */ }
858
+ }
859
+
860
+ for (const dir of searchDirs) {
861
+ for (const pattern of patterns) {
862
+ const candidate = path.join(dir, pattern);
863
+ try {
864
+ if (fs.existsSync(candidate)) return candidate;
865
+ } catch (e) { /* skip */ }
866
+ }
867
+ }
868
+
869
+ return null;
870
+ }
871
+
872
+ /**
873
+ * Extract CHILD_ID values from a getChildVariables HIERARCHIES response.
874
+ * Returns IDs whose PARENT_ID is one of the given parent groups.
875
+ * Skips virtual group IDs (starting with @) — those are containers, not real variables.
876
+ *
877
+ * @param {string} xml - Response body from getChildVariables
878
+ * @param {string[]} parents - Parent group IDs to include (e.g. ['@PARAMETERS','@LOCALS','@ROOT'])
879
+ * @returns {string[]}
880
+ */
881
+ function extractChildIds(xml, parents) {
882
+ const ids = [];
883
+ const re = /<STPDA_ADT_VARIABLE_HIERARCHY>([\s\S]*?)<\/STPDA_ADT_VARIABLE_HIERARCHY>/gi;
884
+ let m;
885
+ while ((m = re.exec(xml)) !== null) {
886
+ const inner = m[1];
887
+ const parentId = (inner.match(/<PARENT_ID>([^<]*)<\/PARENT_ID>/) || [])[1] || '';
888
+ const childId = (inner.match(/<CHILD_ID>([^<]*)<\/CHILD_ID>/) || [])[1] || '';
889
+ if (parents.includes(parentId) && childId && !childId.startsWith('@')) {
890
+ ids.push(childId);
891
+ }
892
+ }
893
+ // Deduplicate (same ID may appear under multiple parents)
894
+ return [...new Set(ids)];
895
+ }
896
+
897
+ module.exports = { DebugSession, parseVariables, parseStack, extractFriendlyName };