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.
- package/README.md +27 -0
- package/abap/CLAUDE.md +143 -0
- package/bin/abapgit-agent +2 -0
- package/package.json +7 -1
- package/src/commands/debug.js +1390 -0
- package/src/commands/dump.js +327 -0
- package/src/commands/help.js +6 -1
- package/src/utils/adt-http.js +344 -0
- package/src/utils/debug-daemon.js +207 -0
- package/src/utils/debug-render.js +69 -0
- package/src/utils/debug-repl.js +256 -0
- package/src/utils/debug-session.js +845 -0
- package/src/utils/debug-state.js +124 -0
|
@@ -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 <LS_REQUEST> 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(/</g, '<')
|
|
658
|
+
.replace(/>/g, '>')
|
|
659
|
+
.replace(/&/g, '&')
|
|
660
|
+
.replace(/"/g, '"')
|
|
661
|
+
.replace(/'/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 };
|