abapgit-agent 1.8.9 → 1.10.0

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