@cspeach/cli 0.8.0 → 1.0.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 +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored insertion for ABAP CDS / DDLX source (B1 of the revision-aware
|
|
3
|
+
* design). We deliberately do NOT parse the DSL into a model and re-serialize
|
|
4
|
+
* — a lossless CDS/BDEF round-trip is a weeks-long sub-project and a half-built
|
|
5
|
+
* serializer corrupts more than it protects. Instead we locate a stable
|
|
6
|
+
* textual anchor and insert the new element there, leaving every other byte
|
|
7
|
+
* untouched. The corruption surface is the inserted span only; the real safety
|
|
8
|
+
* net is the skill's snapshot → preview-diff → syntax-check → activate-verify →
|
|
9
|
+
* rollback chain around this pure function.
|
|
10
|
+
*
|
|
11
|
+
* See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md §5.2.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Base indentation of the element whose final line is `lastIdx`: the
|
|
15
|
+
* shallowest indentation among the contiguous run of non-empty lines ending
|
|
16
|
+
* at `lastIdx`, scanning upward and stopping at the first blank line (the
|
|
17
|
+
* element separator) or the first line that leaves the view/annotate body
|
|
18
|
+
* (a zero-indent line such as the opening `{`).
|
|
19
|
+
*
|
|
20
|
+
* Annotation lines and the element's first line sit at this base indent;
|
|
21
|
+
* continuation lines of a multi-line expression are deeper, so the minimum
|
|
22
|
+
* yields the element's own indentation rather than a deep continuation line.
|
|
23
|
+
* Returns '' if no indented line is found (caller may apply a default).
|
|
24
|
+
*/
|
|
25
|
+
function baseIndentOfLastElement(lines, lastIdx) {
|
|
26
|
+
let minIndent = '';
|
|
27
|
+
let minLen = Infinity;
|
|
28
|
+
for (let i = lastIdx; i >= 0; i--) {
|
|
29
|
+
const trimmed = lines[i].trim();
|
|
30
|
+
if (trimmed === '')
|
|
31
|
+
break; // element separator
|
|
32
|
+
const indent = (lines[i].match(/^\s*/) ?? [''])[0];
|
|
33
|
+
if (indent.length === 0)
|
|
34
|
+
break; // left the body (e.g. the opening brace)
|
|
35
|
+
if (indent.length < minLen) {
|
|
36
|
+
minLen = indent.length;
|
|
37
|
+
minIndent = indent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return minIndent;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Insert a new field into a CDS `define view [entity] … { … }` element list.
|
|
44
|
+
*
|
|
45
|
+
* The new field is appended as the last element before the body's closing
|
|
46
|
+
* brace, matching the indentation of the existing last element. CDS element
|
|
47
|
+
* lists separate elements with commas and the last element carries no trailing
|
|
48
|
+
* comma, so the previously-last element gains a comma.
|
|
49
|
+
*
|
|
50
|
+
* @param source full CDS view source
|
|
51
|
+
* @param fieldDef the element to add, e.g. `Priority` or `priority as Priority`
|
|
52
|
+
* (no leading indentation, no trailing comma — both are applied)
|
|
53
|
+
* @returns the modified source
|
|
54
|
+
*/
|
|
55
|
+
export function insertCdsField(source, fieldDef) {
|
|
56
|
+
const lines = source.split('\n');
|
|
57
|
+
// Anchor: the view body's closing brace — the last line that is just '}'.
|
|
58
|
+
let braceIdx = -1;
|
|
59
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
60
|
+
if (lines[i].trim() === '}') {
|
|
61
|
+
braceIdx = i;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (braceIdx === -1) {
|
|
66
|
+
throw new Error('insertCdsField: no closing brace found for the view body');
|
|
67
|
+
}
|
|
68
|
+
// The last existing element: the last non-empty line before the brace.
|
|
69
|
+
let lastIdx = -1;
|
|
70
|
+
for (let i = braceIdx - 1; i >= 0; i--) {
|
|
71
|
+
if (lines[i].trim() !== '') {
|
|
72
|
+
lastIdx = i;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (lastIdx === -1) {
|
|
77
|
+
throw new Error('insertCdsField: no element found in the view body');
|
|
78
|
+
}
|
|
79
|
+
// Reuse the last element's *base* indentation for the new one. The last
|
|
80
|
+
// element may span multiple lines (e.g. a multi-line CASE/CAST expression)
|
|
81
|
+
// whose final line is indented far deeper than the element's own start.
|
|
82
|
+
// Reusing that final line's indent would mis-indent the new element, so
|
|
83
|
+
// derive the base indent from the shallowest line of the element's block.
|
|
84
|
+
const indent = baseIndentOfLastElement(lines, lastIdx);
|
|
85
|
+
// Ensure the prior last element ends with a comma.
|
|
86
|
+
if (!lines[lastIdx].trimEnd().endsWith(',')) {
|
|
87
|
+
lines[lastIdx] = `${lines[lastIdx].trimEnd()},`;
|
|
88
|
+
}
|
|
89
|
+
// Insert the new field immediately before the closing brace.
|
|
90
|
+
lines.splice(braceIdx, 0, `${indent}${fieldDef}`);
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Insert an `@UI.lineItem`-annotated field into a DDLX metadata extension
|
|
95
|
+
* (`annotate view … with { … }`) so the field appears as a column in a Fiori
|
|
96
|
+
* Elements List Report. The annotated field block is appended before the
|
|
97
|
+
* body's closing brace, matching the indentation of the existing entries.
|
|
98
|
+
*
|
|
99
|
+
* DDLX entries are `;`-terminated independently, so — unlike a CDS element
|
|
100
|
+
* list — no prior entry needs editing.
|
|
101
|
+
*
|
|
102
|
+
* @param source full DDLX metadata-extension source
|
|
103
|
+
* @param field the field name to expose as a column, e.g. `Priority`
|
|
104
|
+
* @param position the `@UI.lineItem` position (column order)
|
|
105
|
+
* @returns the modified source
|
|
106
|
+
*/
|
|
107
|
+
export function insertDdlxLineItem(source, field, position) {
|
|
108
|
+
const lines = source.split('\n');
|
|
109
|
+
// Anchor: the body's closing brace — the last line that is just '}'.
|
|
110
|
+
let braceIdx = -1;
|
|
111
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
112
|
+
if (lines[i].trim() === '}') {
|
|
113
|
+
braceIdx = i;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (braceIdx === -1) {
|
|
118
|
+
throw new Error('insertDdlxLineItem: no closing brace found for the annotate body');
|
|
119
|
+
}
|
|
120
|
+
// Reuse the base indentation of the last entry (default 2 spaces). Using
|
|
121
|
+
// the element's shallowest line keeps the new entry aligned even when the
|
|
122
|
+
// last entry spans multiple lines.
|
|
123
|
+
let lastIdx = -1;
|
|
124
|
+
for (let i = braceIdx - 1; i >= 0; i--) {
|
|
125
|
+
const trimmed = lines[i].trim();
|
|
126
|
+
if (trimmed !== '' && trimmed !== '{') {
|
|
127
|
+
lastIdx = i;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const indent = lastIdx === -1 ? ' ' : baseIndentOfLastElement(lines, lastIdx) || ' ';
|
|
132
|
+
// Insert the annotation line followed by the field line.
|
|
133
|
+
lines.splice(braceIdx, 0, `${indent}@UI.lineItem: [{ position: ${position} }]`, `${indent}${field};`);
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Promote an element that is ALREADY exposed in a CDS `define view [entity]`
|
|
138
|
+
* projection to a Fiori Elements list column, by inserting an
|
|
139
|
+
* `@UI.lineItem` annotation directly above the element's declaration line.
|
|
140
|
+
*
|
|
141
|
+
* Unlike {@link insertCdsField} (which APPENDS a new element to the body) this
|
|
142
|
+
* function annotates a field that is already in the element list — closing the
|
|
143
|
+
* gap where the model only knew how to add fields, not surface existing ones.
|
|
144
|
+
*
|
|
145
|
+
* The anchor is the element's own declaration line, located by matching the
|
|
146
|
+
* declared element name (the alias after `as`, or the bare/`key`-prefixed
|
|
147
|
+
* name). The new annotation is inserted immediately above that line at the
|
|
148
|
+
* same indentation, so it joins any existing contiguous annotation block.
|
|
149
|
+
*
|
|
150
|
+
* Fail-safe by design:
|
|
151
|
+
* - Refuses (throws) when the field cannot be uniquely identified (not found,
|
|
152
|
+
* or ambiguous across multiple declarations).
|
|
153
|
+
* - Refuses multi-line / computed elements (e.g. a `case … end as Name`): the
|
|
154
|
+
* declaration line is a continuation of a larger expression, and inserting
|
|
155
|
+
* above it would split that expression. Such elements must be promoted
|
|
156
|
+
* manually or via /abap-refactor.
|
|
157
|
+
* - Idempotent: refuses when the field already carries an `@UI.lineItem` in the
|
|
158
|
+
* contiguous annotation block directly above it, so re-running is a no-op
|
|
159
|
+
* error rather than a duplicate column.
|
|
160
|
+
*
|
|
161
|
+
* @param source full CDS view source
|
|
162
|
+
* @param field the EXISTING exposed element name to promote, e.g. `CreatedBy`
|
|
163
|
+
* @param position the `@UI.lineItem` position (column order)
|
|
164
|
+
* @returns the modified source
|
|
165
|
+
*/
|
|
166
|
+
export function promoteCdsFieldToLineItem(source, field, position) {
|
|
167
|
+
const lines = source.split('\n');
|
|
168
|
+
// The element name a line declares, or null. Strip trailing whitespace, then
|
|
169
|
+
// a single optional trailing comma, then trailing whitespace again:
|
|
170
|
+
// `expr as Name` -> Name (aliased element)
|
|
171
|
+
// `key Name` / `Name` (bare) -> Name
|
|
172
|
+
// anything else (continuation, brace, annotation, expression) -> null
|
|
173
|
+
const declaredName = (line) => {
|
|
174
|
+
const s = line.replace(/\s+$/, '').replace(/,$/, '').replace(/\s+$/, '');
|
|
175
|
+
const aliased = s.match(/\bas\s+(\w+)$/);
|
|
176
|
+
if (aliased)
|
|
177
|
+
return aliased[1];
|
|
178
|
+
const bare = s.trim().match(/^(?:key\s+)?(\w+)$/);
|
|
179
|
+
if (bare)
|
|
180
|
+
return bare[1];
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
// 1. Locate the unique element declaring `field`.
|
|
184
|
+
const defIdxs = [];
|
|
185
|
+
for (let i = 0; i < lines.length; i++) {
|
|
186
|
+
if (declaredName(lines[i]) === field)
|
|
187
|
+
defIdxs.push(i);
|
|
188
|
+
}
|
|
189
|
+
if (defIdxs.length === 0) {
|
|
190
|
+
throw new Error(`promoteCdsFieldToLineItem: field '${field}' not found in the view element list`);
|
|
191
|
+
}
|
|
192
|
+
if (defIdxs.length > 1) {
|
|
193
|
+
throw new Error(`promoteCdsFieldToLineItem: field '${field}' is ambiguous (${defIdxs.length} matches)`);
|
|
194
|
+
}
|
|
195
|
+
const defIdx = defIdxs[0];
|
|
196
|
+
// 2. Refuse if defIdx is a continuation line of a multi-line element —
|
|
197
|
+
// inserting above it would split the expression. A "fresh start" (safe)
|
|
198
|
+
// is only when the line above is blank, the opening brace, an annotation,
|
|
199
|
+
// or the previous element's terminator (ends with a comma).
|
|
200
|
+
const prev = defIdx > 0 ? lines[defIdx - 1].trim() : '';
|
|
201
|
+
const isFreshStart = prev === '' || prev === '{' || prev.startsWith('@') || prev.endsWith(',');
|
|
202
|
+
if (!isFreshStart) {
|
|
203
|
+
throw new Error(`promoteCdsFieldToLineItem: '${field}' resolves to a multi-line/computed element; promote it manually or via /abap-refactor`);
|
|
204
|
+
}
|
|
205
|
+
// 3. Idempotency: refuse if `field` already has an @UI.lineItem in the
|
|
206
|
+
// contiguous single-line annotation block directly above defIdx.
|
|
207
|
+
for (let i = defIdx - 1; i >= 0 && lines[i].trim().startsWith('@'); i--) {
|
|
208
|
+
if (/@UI\.lineItem\b/.test(lines[i])) {
|
|
209
|
+
throw new Error(`promoteCdsFieldToLineItem: '${field}' is already exposed as a column (@UI.lineItem present)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// 4. Insert the annotation immediately above defIdx, matching defIdx's indent.
|
|
213
|
+
const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
|
|
214
|
+
lines.splice(defIdx, 0, `${indent}@UI.lineItem: [{ position: ${position} }]`);
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Add a NON-KEY field to a CDS `define table NAME { … }` body (B2a — the first
|
|
219
|
+
* leg of the table→CDS→BDEF single-field chain).
|
|
220
|
+
*
|
|
221
|
+
* Like {@link insertDdlxLineItem}, table fields are `;`-terminated independently
|
|
222
|
+
* — there is no comma-separated element list — so NO prior line is edited; only
|
|
223
|
+
* the new line is inserted. The new field is appended as the last entry before
|
|
224
|
+
* the body's closing brace, at the indentation of the existing last entry.
|
|
225
|
+
*
|
|
226
|
+
* No DSL parser is used: the anchor is the textual closing brace (the last line
|
|
227
|
+
* whose trim() === '}'), exactly as the CDS/DDLX helpers do.
|
|
228
|
+
*
|
|
229
|
+
* Fail-safe by design:
|
|
230
|
+
* - Refuses (throws) a `key …` clause: adding/removing a key is a destructive
|
|
231
|
+
* DDIC table-conversion, never a safe append. Only non-key fields at the end
|
|
232
|
+
* are supported.
|
|
233
|
+
* - Throws when no closing brace can be found for the table body.
|
|
234
|
+
*
|
|
235
|
+
* @param source full CDS `define table` source
|
|
236
|
+
* @param fieldClause the field definition WITHOUT a trailing `;`, e.g.
|
|
237
|
+
* `reference : zde_reference` or `reference : abap.char(16)`.
|
|
238
|
+
* Any trailing `;`/whitespace is stripped and exactly one
|
|
239
|
+
* `;` is re-applied.
|
|
240
|
+
* @returns the modified source
|
|
241
|
+
*/
|
|
242
|
+
export function addTableField(source, fieldClause) {
|
|
243
|
+
// Fail-safe: never touch keys.
|
|
244
|
+
if (/^\s*key\s/i.test(fieldClause)) {
|
|
245
|
+
throw new Error('addTableField: refusing to add a key field (key changes are destructive); only non-key fields at the end are supported');
|
|
246
|
+
}
|
|
247
|
+
// Normalize: strip trailing whitespace, then any trailing ';', then
|
|
248
|
+
// whitespace again — the function re-adds exactly one ';'.
|
|
249
|
+
const normalizedClause = fieldClause.replace(/\s+$/, '').replace(/;$/, '').replace(/\s+$/, '');
|
|
250
|
+
const lines = source.split('\n');
|
|
251
|
+
// Anchor: the body's closing brace — the last line that is just '}'.
|
|
252
|
+
let braceIdx = -1;
|
|
253
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
254
|
+
if (lines[i].trim() === '}') {
|
|
255
|
+
braceIdx = i;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (braceIdx === -1) {
|
|
260
|
+
throw new Error('addTableField: no closing brace found for the table body');
|
|
261
|
+
}
|
|
262
|
+
// Reuse the base indent of the last entry (default 2 spaces).
|
|
263
|
+
let lastIdx = -1;
|
|
264
|
+
for (let i = braceIdx - 1; i >= 0; i--) {
|
|
265
|
+
const trimmed = lines[i].trim();
|
|
266
|
+
if (trimmed !== '' && trimmed !== '{') {
|
|
267
|
+
lastIdx = i;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const indent = lastIdx === -1 ? ' ' : baseIndentOfLastElement(lines, lastIdx) || ' ';
|
|
272
|
+
// Insert the new field immediately before the closing brace.
|
|
273
|
+
lines.splice(braceIdx, 0, `${indent}${normalizedClause};`);
|
|
274
|
+
return lines.join('\n');
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the behavior entity body span (bodyOpen..bodyClose) for the chosen
|
|
278
|
+
* behavior, and REPORT the implementation keyword and resolved alias. Shared by
|
|
279
|
+
* insertBdefField, insertBdefClause, and resolveBehaviorAlias. Fail-safe:
|
|
280
|
+
* refuses ambiguity and a missing body.
|
|
281
|
+
*
|
|
282
|
+
* IMPORTANT (extensibility seam, spec §9): this resolver does NOT refuse
|
|
283
|
+
* unmanaged — it returns `implKeyword` and the CALLERS enforce the managed-only
|
|
284
|
+
* policy. A future unmanaged/saver capability flips that caller policy without
|
|
285
|
+
* touching this shared helper. (Extracted from insertBdefField — existing
|
|
286
|
+
* insertBdefField tests guard the refactor.)
|
|
287
|
+
*/
|
|
288
|
+
function resolveBehaviorEntityBody(lines, alias) {
|
|
289
|
+
const behaviors = [];
|
|
290
|
+
for (let i = 0; i < lines.length; i++) {
|
|
291
|
+
const m = lines[i].match(/\bdefine\s+behavior\s+for\s+\S+\s+alias\s+(\w+)/i);
|
|
292
|
+
if (m)
|
|
293
|
+
behaviors.push({ lineIdx: i, alias: m[1] });
|
|
294
|
+
}
|
|
295
|
+
if (behaviors.length === 0) {
|
|
296
|
+
throw new Error('no "define behavior … alias …" found in the source');
|
|
297
|
+
}
|
|
298
|
+
let chosen;
|
|
299
|
+
if (alias) {
|
|
300
|
+
const wanted = alias.toLowerCase();
|
|
301
|
+
const match = behaviors.find((b) => b.alias.toLowerCase() === wanted);
|
|
302
|
+
if (!match)
|
|
303
|
+
throw new Error(`alias '${alias}' not found among the defined behaviors`);
|
|
304
|
+
chosen = match;
|
|
305
|
+
}
|
|
306
|
+
else if (behaviors.length === 1) {
|
|
307
|
+
chosen = behaviors[0];
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
throw new Error('multiple behaviors; specify alias');
|
|
311
|
+
}
|
|
312
|
+
let implKeyword = null;
|
|
313
|
+
for (let i = chosen.lineIdx; i >= 0; i--) {
|
|
314
|
+
const m = lines[i].match(/\b(managed|unmanaged)\s+implementation\s+in\s+class\b/i);
|
|
315
|
+
if (m) {
|
|
316
|
+
implKeyword = m[1].toLowerCase();
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
let bodyOpen = -1;
|
|
321
|
+
for (let i = chosen.lineIdx; i < lines.length; i++) {
|
|
322
|
+
if (lines[i].includes('{')) {
|
|
323
|
+
bodyOpen = i;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (bodyOpen === -1)
|
|
328
|
+
throw new Error(`no body '{' found for alias ${chosen.alias}`);
|
|
329
|
+
const bodyClose = matchingClose(lines, bodyOpen);
|
|
330
|
+
if (bodyClose === -1)
|
|
331
|
+
throw new Error(`no matching '}' found for alias ${chosen.alias}`);
|
|
332
|
+
return { bodyOpen, bodyClose, implKeyword, alias: chosen.alias };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Resolve the effective BDEF entity alias for a given source string: auto-picks
|
|
336
|
+
* the single behavior when no alias is given, inheriting the same ambiguity /
|
|
337
|
+
* not-found / no-behavior errors from resolveBehaviorEntityBody. Intended as the
|
|
338
|
+
* thin alias-resolution entry point for handleBdefStub (I-A).
|
|
339
|
+
*/
|
|
340
|
+
export function resolveBehaviorAlias(source, alias) {
|
|
341
|
+
return resolveBehaviorEntityBody(source.split('\n'), alias).alias;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Add a `field ( … )` declaration or a mapping line to a MANAGED RAP behavior
|
|
345
|
+
* definition (B2a — the final leg of the table→CDS→BDEF chain).
|
|
346
|
+
*
|
|
347
|
+
* No DSL parser is used. The behavior entity body and the mapping block are
|
|
348
|
+
* located by their textual braces using simple `{`/`}` counting (BDEF rarely
|
|
349
|
+
* carries braces inside strings, so raw counting is sufficient and far safer
|
|
350
|
+
* than a half-built parser). Every other byte is left untouched.
|
|
351
|
+
*
|
|
352
|
+
* Fail-safe by design:
|
|
353
|
+
* - Refuses (throws) an UNMANAGED implementation — only managed BDEF is
|
|
354
|
+
* supported (the chain auto-persists; unmanaged needs hand-written savers).
|
|
355
|
+
* - When several behaviors exist and no `alias` is given, throws rather than
|
|
356
|
+
* guessing which entity to extend; throws too if a requested alias is absent.
|
|
357
|
+
* - target 'mapping' with no mapping block throws rather than silently adding
|
|
358
|
+
* nothing.
|
|
359
|
+
*
|
|
360
|
+
* @param source full managed BDEF source
|
|
361
|
+
* @param opts.alias the entity alias to extend (`alias <ALIAS>`); optional only
|
|
362
|
+
* when exactly one behavior is defined
|
|
363
|
+
* @param opts.target 'field' → add a `field ( … ) Name;` to the entity body;
|
|
364
|
+
* 'mapping' → add `Name = column;` to the entity's mapping block
|
|
365
|
+
* @param opts.clause the declaration WITHOUT a trailing `;`. For 'field', the
|
|
366
|
+
* full `field ( readonly ) Reference`; for 'mapping', the
|
|
367
|
+
* assignment `Reference = reference`. Exactly one `;` is
|
|
368
|
+
* re-applied.
|
|
369
|
+
* @returns the modified source
|
|
370
|
+
*/
|
|
371
|
+
export function insertBdefField(source, opts) {
|
|
372
|
+
const lines = source.split('\n');
|
|
373
|
+
// Normalize the clause: strip trailing whitespace + one trailing ';'.
|
|
374
|
+
const clause = opts.clause.replace(/\s+$/, '').replace(/;$/, '').replace(/\s+$/, '');
|
|
375
|
+
// 1–3. Resolve behavior entity body via shared helper; enforce managed-only
|
|
376
|
+
// at this call site (caller policy per spec §9 extensibility seam).
|
|
377
|
+
const { bodyOpen, bodyClose, implKeyword } = resolveBehaviorEntityBody(lines, opts.alias);
|
|
378
|
+
if (implKeyword === 'unmanaged') {
|
|
379
|
+
throw new Error('insertBdefField: unmanaged BDEF not supported (managed only)');
|
|
380
|
+
}
|
|
381
|
+
if (opts.target === 'field') {
|
|
382
|
+
// 4. Insert after the LAST `field (` line inside the body; else just
|
|
383
|
+
// before bodyClose at the body indent.
|
|
384
|
+
let lastFieldIdx = -1;
|
|
385
|
+
for (let i = bodyOpen + 1; i < bodyClose; i++) {
|
|
386
|
+
if (/^\s*field\s*\(/.test(lines[i]))
|
|
387
|
+
lastFieldIdx = i;
|
|
388
|
+
}
|
|
389
|
+
if (lastFieldIdx !== -1) {
|
|
390
|
+
const indent = (lines[lastFieldIdx].match(/^\s*/) ?? [''])[0];
|
|
391
|
+
lines.splice(lastFieldIdx + 1, 0, `${indent}${clause};`);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const indent = bodyIndent(lines, bodyOpen, bodyClose);
|
|
395
|
+
lines.splice(bodyClose, 0, `${indent}${clause};`);
|
|
396
|
+
}
|
|
397
|
+
return lines.join('\n');
|
|
398
|
+
}
|
|
399
|
+
// 5. target 'mapping': find a `mapping for …` line inside the body, then its
|
|
400
|
+
// block braces, and insert before the mapping block's close.
|
|
401
|
+
let mappingLineIdx = -1;
|
|
402
|
+
for (let i = bodyOpen + 1; i < bodyClose; i++) {
|
|
403
|
+
if (/^\s*mapping\s+for\b/.test(lines[i])) {
|
|
404
|
+
mappingLineIdx = i;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (mappingLineIdx === -1) {
|
|
409
|
+
throw new Error(`insertBdefField: no mapping block found for alias ${opts.alias ?? '(single)'}`);
|
|
410
|
+
}
|
|
411
|
+
let mapOpen = -1;
|
|
412
|
+
for (let i = mappingLineIdx; i < bodyClose; i++) {
|
|
413
|
+
if (lines[i].includes('{')) {
|
|
414
|
+
mapOpen = i;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (mapOpen === -1) {
|
|
419
|
+
throw new Error(`insertBdefField: no mapping block found for alias ${opts.alias ?? '(single)'}`);
|
|
420
|
+
}
|
|
421
|
+
const mapClose = matchingClose(lines, mapOpen);
|
|
422
|
+
if (mapClose === -1) {
|
|
423
|
+
throw new Error(`insertBdefField: no matching '}' for the mapping block of alias ${opts.alias ?? '(single)'}`);
|
|
424
|
+
}
|
|
425
|
+
// Indent from an existing mapping entry; default to the mapping line's
|
|
426
|
+
// indent + 2 spaces.
|
|
427
|
+
let mapEntryIndent = null;
|
|
428
|
+
for (let i = mapOpen + 1; i < mapClose; i++) {
|
|
429
|
+
if (lines[i].trim() !== '') {
|
|
430
|
+
mapEntryIndent = (lines[i].match(/^\s*/) ?? [''])[0];
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (mapEntryIndent === null) {
|
|
435
|
+
const mapLineIndent = (lines[mappingLineIdx].match(/^\s*/) ?? [''])[0];
|
|
436
|
+
mapEntryIndent = `${mapLineIndent} `;
|
|
437
|
+
}
|
|
438
|
+
lines.splice(mapClose, 0, `${mapEntryIndent}${clause};`);
|
|
439
|
+
return lines.join('\n');
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Insert a complete BDEF clause line (a validation/determination/action built
|
|
443
|
+
* by buildClauseLine) into the chosen MANAGED entity body, immediately before
|
|
444
|
+
* the body's closing brace, at the body indentation. Managed-only policy is
|
|
445
|
+
* enforced HERE (the caller), not in the shared resolver (spec §9). See §4.1.
|
|
446
|
+
*/
|
|
447
|
+
export function insertBdefClause(source, opts) {
|
|
448
|
+
const lines = source.split('\n');
|
|
449
|
+
const { bodyOpen, bodyClose, implKeyword } = resolveBehaviorEntityBody(lines, opts.alias);
|
|
450
|
+
if (implKeyword === 'unmanaged') {
|
|
451
|
+
throw new Error('insertBdefClause: unmanaged BDEF not supported (managed only)');
|
|
452
|
+
}
|
|
453
|
+
const indent = bodyIndent(lines, bodyOpen, bodyClose);
|
|
454
|
+
lines.splice(bodyClose, 0, `${indent}${opts.clauseLine}`);
|
|
455
|
+
return lines.join('\n');
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Index of the line carrying the '}' that matches the FIRST '{' on line
|
|
459
|
+
* `openIdx`, counting '{'/'}' across all lines (raw count — BDEF rarely has
|
|
460
|
+
* braces inside string literals). Returns -1 if unbalanced.
|
|
461
|
+
*/
|
|
462
|
+
function matchingClose(lines, openIdx) {
|
|
463
|
+
let depth = 0;
|
|
464
|
+
let seenOpen = false;
|
|
465
|
+
for (let i = openIdx; i < lines.length; i++) {
|
|
466
|
+
for (const ch of lines[i]) {
|
|
467
|
+
if (ch === '{') {
|
|
468
|
+
depth++;
|
|
469
|
+
seenOpen = true;
|
|
470
|
+
}
|
|
471
|
+
else if (ch === '}') {
|
|
472
|
+
depth--;
|
|
473
|
+
if (seenOpen && depth === 0)
|
|
474
|
+
return i;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return -1;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Indentation to use for a new line inside an entity body that has no `field (`
|
|
482
|
+
* anchor: derived from the first non-empty body line, else the body-open
|
|
483
|
+
* line's indent + 2 spaces.
|
|
484
|
+
*/
|
|
485
|
+
function bodyIndent(lines, bodyOpen, bodyClose) {
|
|
486
|
+
for (let i = bodyOpen + 1; i < bodyClose; i++) {
|
|
487
|
+
if (lines[i].trim() !== '')
|
|
488
|
+
return (lines[i].match(/^\s*/) ?? [''])[0];
|
|
489
|
+
}
|
|
490
|
+
const openIndent = (lines[bodyOpen].match(/^\s*/) ?? [''])[0];
|
|
491
|
+
return `${openIndent} `;
|
|
492
|
+
}
|
|
493
|
+
function stubImpl(name, extraComment) {
|
|
494
|
+
const body = extraComment
|
|
495
|
+
? ` " TODO: implement business logic — use /abap-eml\n " ${extraComment}`
|
|
496
|
+
: ' " TODO: implement business logic — use /abap-eml';
|
|
497
|
+
return `METHOD ${name}.\n${body}\nENDMETHOD.`;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Build the CCIMP handler method (declaration + empty implementation) for a
|
|
501
|
+
* stub. Signatures verified against ABAP keyword docs ABAPHANDLER_METH_MODIFY
|
|
502
|
+
* (action: REQUEST is optional; the input parameter is delivered via
|
|
503
|
+
* keys-%param). `<Entity>` is the BDEF alias. See spec §4.1.
|
|
504
|
+
*/
|
|
505
|
+
export function buildHandlerMethod(kind, opts) {
|
|
506
|
+
const e = opts.entity;
|
|
507
|
+
if (kind === 'validation') {
|
|
508
|
+
return {
|
|
509
|
+
decl: `METHODS ${opts.name} FOR VALIDATE ON SAVE IMPORTING keys FOR ${e}~${opts.name}.`,
|
|
510
|
+
impl: stubImpl(opts.name),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (kind === 'determination') {
|
|
514
|
+
const on = opts.trigger === 'save' ? 'SAVE' : 'MODIFY';
|
|
515
|
+
return {
|
|
516
|
+
decl: `METHODS ${opts.name} FOR DETERMINE ON ${on} IMPORTING keys FOR ${e}~${opts.name}.`,
|
|
517
|
+
impl: stubImpl(opts.name),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
// action
|
|
521
|
+
const resultClause = opts.result ? ' RESULT result' : '';
|
|
522
|
+
const method = {
|
|
523
|
+
decl: `METHODS ${opts.name} FOR MODIFY IMPORTING keys FOR ACTION ${e}~${opts.name}${resultClause}.`,
|
|
524
|
+
impl: stubImpl(opts.name, opts.parameter ? 'input parameter is in keys-%param' : undefined),
|
|
525
|
+
};
|
|
526
|
+
if (opts.features) {
|
|
527
|
+
method.featuresDecl = `METHODS get_instance_features FOR INSTANCE FEATURES IMPORTING keys REQUEST requested_features FOR ${e} RESULT result.`;
|
|
528
|
+
method.featuresImpl = `METHOD get_instance_features.\n " TODO: populate result with the %action-${opts.name} enabled-state — use /abap-eml\nENDMETHOD.`;
|
|
529
|
+
}
|
|
530
|
+
return method;
|
|
531
|
+
}
|
|
532
|
+
/** True if the line is a full-line ABAP comment (`*` in col 1) — skipped when scanning. */
|
|
533
|
+
function isCommentLine(line) {
|
|
534
|
+
return /^\s*\*/.test(line) || line.trimStart().startsWith('"');
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Locate a named local class's DEFINITION and IMPLEMENTATION spans by ABAP
|
|
538
|
+
* keyword matching (NOT brace counting — ABAP classes have no braces, so the
|
|
539
|
+
* B2a `matchingClose` primitive does not apply). Case-insensitive; skips
|
|
540
|
+
* full-line comments; tolerant of multiple classes in one CCIMP include.
|
|
541
|
+
* Returns null if either block is missing. See spec §4.1 (review C4).
|
|
542
|
+
*/
|
|
543
|
+
export function classBlockSpan(lines, className) {
|
|
544
|
+
const name = className.toLowerCase();
|
|
545
|
+
const defRe = new RegExp(`^\\s*class\\s+${name}\\s+definition\\b`, 'i');
|
|
546
|
+
const implRe = new RegExp(`^\\s*class\\s+${name}\\s+implementation\\b`, 'i');
|
|
547
|
+
const endRe = /^\s*endclass\s*\./i;
|
|
548
|
+
const findBlock = (headerRe) => {
|
|
549
|
+
for (let i = 0; i < lines.length; i++) {
|
|
550
|
+
if (isCommentLine(lines[i]))
|
|
551
|
+
continue;
|
|
552
|
+
if (headerRe.test(lines[i])) {
|
|
553
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
554
|
+
if (isCommentLine(lines[j]))
|
|
555
|
+
continue;
|
|
556
|
+
if (endRe.test(lines[j]))
|
|
557
|
+
return { start: i, end: j };
|
|
558
|
+
}
|
|
559
|
+
return null; // header without ENDCLASS
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
};
|
|
564
|
+
const def = findBlock(defRe);
|
|
565
|
+
const impl = findBlock(implRe);
|
|
566
|
+
if (!def || !impl)
|
|
567
|
+
return null;
|
|
568
|
+
return { defStart: def.start, defEnd: def.end, implStart: impl.start, implEnd: impl.end };
|
|
569
|
+
}
|
|
570
|
+
/** The method name a `METHODS <name> …` declaration line declares (lowercased), or null. */
|
|
571
|
+
function declaredMethodName(declLine) {
|
|
572
|
+
const m = declLine.match(/^\s*methods\s+(\w+)/i);
|
|
573
|
+
return m ? m[1].toLowerCase() : null;
|
|
574
|
+
}
|
|
575
|
+
/** Apply a base indent to every non-empty line of a multi-line block. */
|
|
576
|
+
function indentBlock(block, indent) {
|
|
577
|
+
return block
|
|
578
|
+
.split('\n')
|
|
579
|
+
.map((l) => (l === '' ? '' : `${indent}${l}`))
|
|
580
|
+
.join('\n');
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Insert a handler method (decl into PRIVATE/PROTECTED SECTION, impl before
|
|
584
|
+
* the IMPLEMENTATION ENDCLASS) into an EXISTING lhc_<entity> class, scoped via
|
|
585
|
+
* classBlockSpan. Idempotent (refuses a duplicate method name). For a
|
|
586
|
+
* feature-controlled action, also inserts the get_instance_features pair, but
|
|
587
|
+
* only if that method is not already present. See spec §4.1 / §4.4.
|
|
588
|
+
*/
|
|
589
|
+
export function insertHandlerMethod(ccimp, opts) {
|
|
590
|
+
const className = `lhc_${opts.entity.toLowerCase()}`;
|
|
591
|
+
const lines = ccimp.split('\n');
|
|
592
|
+
const span = classBlockSpan(lines, className);
|
|
593
|
+
if (!span)
|
|
594
|
+
throw new Error(`insertHandlerMethod: no ${className} class found — create a skeleton instead`);
|
|
595
|
+
const { method } = opts;
|
|
596
|
+
// Existing method names within the definition span (idempotency).
|
|
597
|
+
const existingNames = new Set();
|
|
598
|
+
for (let i = span.defStart; i <= span.defEnd; i++) {
|
|
599
|
+
const n = declaredMethodName(lines[i]);
|
|
600
|
+
if (n)
|
|
601
|
+
existingNames.add(n);
|
|
602
|
+
}
|
|
603
|
+
const newName = declaredMethodName(method.decl);
|
|
604
|
+
if (newName && existingNames.has(newName)) {
|
|
605
|
+
throw new Error(`insertHandlerMethod: method '${newName}' already exists in ${className}`);
|
|
606
|
+
}
|
|
607
|
+
// Locate the visibility section header inside the definition span.
|
|
608
|
+
let sectionIdx = -1;
|
|
609
|
+
for (let i = span.defStart + 1; i < span.defEnd; i++) {
|
|
610
|
+
if (/^\s*(private|protected)\s+section\s*\./i.test(lines[i])) {
|
|
611
|
+
sectionIdx = i;
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (sectionIdx === -1) {
|
|
616
|
+
throw new Error(`insertHandlerMethod: no PRIVATE/PROTECTED SECTION found in ${className}`);
|
|
617
|
+
}
|
|
618
|
+
// Indents: decl one level under the section header; impl one level under the class.
|
|
619
|
+
const sectionIndent = (lines[sectionIdx].match(/^\s*/) ?? [''])[0];
|
|
620
|
+
const declIndent = `${sectionIndent} `;
|
|
621
|
+
const implIndent = ((lines[span.implStart].match(/^\s*/) ?? [''])[0]) + ' ';
|
|
622
|
+
const wantFeatures = !!(method.featuresDecl && method.featuresImpl && !existingNames.has('get_instance_features'));
|
|
623
|
+
// Insert IMPL side first (higher line indices) so DEFINITION inserts don't shift implEnd.
|
|
624
|
+
const implInserts = [];
|
|
625
|
+
implInserts.push(indentBlock(method.impl, implIndent));
|
|
626
|
+
if (wantFeatures)
|
|
627
|
+
implInserts.push(indentBlock(method.featuresImpl, implIndent));
|
|
628
|
+
lines.splice(span.implEnd, 0, ...implInserts);
|
|
629
|
+
// Insert DEFINITION side (decl right after the section header).
|
|
630
|
+
const declInserts = [`${declIndent}${method.decl}`];
|
|
631
|
+
if (wantFeatures)
|
|
632
|
+
declInserts.push(`${declIndent}${method.featuresDecl}`);
|
|
633
|
+
lines.splice(sectionIdx + 1, 0, ...declInserts);
|
|
634
|
+
return lines.join('\n');
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Create a new lhc_<entity> behavior handler class (DEFINITION + IMPLEMENTATION)
|
|
638
|
+
* appended to the CCIMP source, when none exists yet (a plain CRUD managed stack
|
|
639
|
+
* has no handler class until the first augmentation). Refuses if the class is
|
|
640
|
+
* already present. See spec §4.1 / §4.4 (review C3).
|
|
641
|
+
*/
|
|
642
|
+
export function createHandlerClassSkeleton(ccimp, opts) {
|
|
643
|
+
const className = `lhc_${opts.entity.toLowerCase()}`;
|
|
644
|
+
if (classBlockSpan(ccimp.split('\n'), className)) {
|
|
645
|
+
throw new Error(`createHandlerClassSkeleton: ${className} already exists — use insertHandlerMethod`);
|
|
646
|
+
}
|
|
647
|
+
const { method } = opts;
|
|
648
|
+
const defLines = [
|
|
649
|
+
`CLASS ${className} DEFINITION INHERITING FROM cl_abap_behavior_handler.`,
|
|
650
|
+
' PRIVATE SECTION.',
|
|
651
|
+
` ${method.decl}`,
|
|
652
|
+
];
|
|
653
|
+
if (method.featuresDecl)
|
|
654
|
+
defLines.push(` ${method.featuresDecl}`);
|
|
655
|
+
defLines.push('ENDCLASS.');
|
|
656
|
+
const implLines = [`CLASS ${className} IMPLEMENTATION.`, indentBlock(method.impl, ' ')];
|
|
657
|
+
if (method.featuresImpl)
|
|
658
|
+
implLines.push(indentBlock(method.featuresImpl, ' '));
|
|
659
|
+
implLines.push('ENDCLASS.');
|
|
660
|
+
const skeleton = [...defLines, '', ...implLines].join('\n');
|
|
661
|
+
const trimmed = ccimp.replace(/\s+$/, '');
|
|
662
|
+
return trimmed === '' ? skeleton : `${trimmed}\n\n${skeleton}`;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Insert a `use <clause>;` line (e.g. `use action Approve;`) into a PROJECTION
|
|
666
|
+
* behavior definition's entity body, before its closing brace. Projection BDEFs
|
|
667
|
+
* carry a top-level `projection;`; this refuses anything else (a base/managed
|
|
668
|
+
* BDEF uses `field`/`validation`/… not `use`). Idempotent: refuses if the same
|
|
669
|
+
* `use <clause>;` is already present. See spec §4.4.
|
|
670
|
+
*/
|
|
671
|
+
export function insertProjectionUse(source, opts) {
|
|
672
|
+
if (!/^\s*projection\s*;/m.test(source)) {
|
|
673
|
+
throw new Error('insertProjectionUse: not a projection BDEF (no top-level `projection;`)');
|
|
674
|
+
}
|
|
675
|
+
const lines = source.split('\n');
|
|
676
|
+
const { bodyOpen, bodyClose } = resolveBehaviorEntityBody(lines, opts.alias);
|
|
677
|
+
const useLine = `use ${opts.useClause.replace(/^use\s+/i, '').replace(/;\s*$/, '')}`;
|
|
678
|
+
// Idempotency by NAME, not exact line (review I3): a grouped or
|
|
679
|
+
// feature-qualified `use ( … ) action Approve;` must still count as present.
|
|
680
|
+
// Match the trailing identifier of the clause (e.g. 'Approve' in 'action Approve').
|
|
681
|
+
const nameMatch = opts.useClause.match(/(\w+)\s*;?\s*$/);
|
|
682
|
+
const name = nameMatch ? nameMatch[1] : null;
|
|
683
|
+
if (name) {
|
|
684
|
+
// Case-SENSITIVE name match (SC-6): RAP action names are PascalCase (e.g. 'Delete'),
|
|
685
|
+
// while CRUD keywords are lowercase ('delete'). A case-insensitive flag would
|
|
686
|
+
// false-positive `use action Delete;` against an existing `use delete;`.
|
|
687
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
688
|
+
const dupRe = new RegExp(`\\buse\\b.*\\b${escapedName}\\b`);
|
|
689
|
+
for (let i = bodyOpen + 1; i < bodyClose; i++) {
|
|
690
|
+
if (dupRe.test(lines[i])) {
|
|
691
|
+
throw new Error(`insertProjectionUse: a 'use … ${name}' is already present in the projection body`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const indent = bodyIndent(lines, bodyOpen, bodyClose);
|
|
696
|
+
lines.splice(bodyClose, 0, `${indent}${useLine};`);
|
|
697
|
+
return lines.join('\n');
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Insert an FE action-button annotation above an existing field in a DDLX
|
|
701
|
+
* metadata extension (or projection @UI). Form (doc-verified vs the SAP Fiori
|
|
702
|
+
* feature showcase): `@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '<a>',
|
|
703
|
+
* label: '<l>', position: <p> }]` on a property — the button renders in the
|
|
704
|
+
* table toolbar. Idempotent: refuses if a lineItem with the same dataAction is
|
|
705
|
+
* already present. See spec §4.4.
|
|
706
|
+
*/
|
|
707
|
+
export function insertMdeActionButton(source, opts) {
|
|
708
|
+
const label = opts.label ?? opts.action;
|
|
709
|
+
const position = typeof opts.position === 'number' ? opts.position : 10;
|
|
710
|
+
// Idempotency: refuse a second FOR_ACTION lineItem for the same action.
|
|
711
|
+
// Escape the action name (defensive — ABAP names are \w+, but never build a
|
|
712
|
+
// RegExp from an unescaped input).
|
|
713
|
+
const esc = opts.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
714
|
+
if (new RegExp(`dataAction:\\s*'${esc}'`).test(source)) {
|
|
715
|
+
throw new Error(`insertMdeActionButton: an action button for '${opts.action}' already exists`);
|
|
716
|
+
}
|
|
717
|
+
const lines = source.split('\n');
|
|
718
|
+
// Anchor: the field's own declaration line (`<field>;` or `<field>`), matched
|
|
719
|
+
// like promoteCdsFieldToLineItem's bare-name rule.
|
|
720
|
+
let defIdx = -1;
|
|
721
|
+
for (let i = 0; i < lines.length; i++) {
|
|
722
|
+
const t = lines[i].trim().replace(/;$/, '').trim();
|
|
723
|
+
if (t === opts.anchorField) {
|
|
724
|
+
defIdx = i;
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (defIdx === -1) {
|
|
729
|
+
throw new Error(`insertMdeActionButton: anchor field '${opts.anchorField}' not found in the metadata extension`);
|
|
730
|
+
}
|
|
731
|
+
// Walk up past lines that start with '@' to find the top of the annotation block.
|
|
732
|
+
let blockStart = defIdx;
|
|
733
|
+
while (blockStart > 0 && lines[blockStart - 1].trim().startsWith('@'))
|
|
734
|
+
blockStart--;
|
|
735
|
+
const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
|
|
736
|
+
// If the annotation block already contains a @UI.lineItem, MERGE the FOR_ACTION
|
|
737
|
+
// entry as the first element of that existing array (one @UI.lineItem per element).
|
|
738
|
+
// Otherwise, insert a standalone @UI.lineItem line above the block.
|
|
739
|
+
let existingLineItemIdx = -1;
|
|
740
|
+
for (let i = blockStart; i < defIdx; i++) {
|
|
741
|
+
if (/@UI\.lineItem\b/.test(lines[i])) {
|
|
742
|
+
existingLineItemIdx = i;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (existingLineItemIdx !== -1) {
|
|
747
|
+
// Find the first '[' on or after the @UI.lineItem line and insert the FOR_ACTION
|
|
748
|
+
// object immediately after it.
|
|
749
|
+
const lineItemLine = lines[existingLineItemIdx];
|
|
750
|
+
const bracketPos = lineItemLine.indexOf('[');
|
|
751
|
+
if (bracketPos === -1) {
|
|
752
|
+
// Malformed — fall through to standalone insert (defensive)
|
|
753
|
+
const ann = `${indent}@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }]`;
|
|
754
|
+
lines.splice(blockStart, 0, ann);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
const forActionEntry = `{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }, `;
|
|
758
|
+
lines[existingLineItemIdx] =
|
|
759
|
+
lineItemLine.slice(0, bracketPos + 1) + forActionEntry + lineItemLine.slice(bracketPos + 1);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
// Standalone: insert a new @UI.lineItem line above the annotation block.
|
|
764
|
+
const ann = `${indent}@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }]`;
|
|
765
|
+
lines.splice(blockStart, 0, ann);
|
|
766
|
+
}
|
|
767
|
+
return lines.join('\n');
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Build a single BDEF clause line for a validation, determination, or action
|
|
771
|
+
* stub. Text only — no insertion. Fail-safe refusals: empty operations,
|
|
772
|
+
* static `$self` result (a static action has no instance), static + instance
|
|
773
|
+
* features. See spec §4.1.
|
|
774
|
+
*/
|
|
775
|
+
export function buildClauseLine(kind, opts) {
|
|
776
|
+
if (kind === 'validation') {
|
|
777
|
+
const ops = (opts.ops ?? 'create; update;').trim();
|
|
778
|
+
if (ops === '')
|
|
779
|
+
throw new Error('buildClauseLine: empty operations set for validation');
|
|
780
|
+
return `validation ${opts.name} on save { ${ops} }`;
|
|
781
|
+
}
|
|
782
|
+
if (kind === 'determination') {
|
|
783
|
+
const trigger = opts.trigger ?? 'modify';
|
|
784
|
+
const ops = (opts.ops ?? 'create;').trim();
|
|
785
|
+
if (ops === '')
|
|
786
|
+
throw new Error('buildClauseLine: empty operations set for determination');
|
|
787
|
+
return `determination ${opts.name} on ${trigger} { ${ops} }`;
|
|
788
|
+
}
|
|
789
|
+
// action
|
|
790
|
+
if (opts.static && opts.features) {
|
|
791
|
+
throw new Error('buildClauseLine: a static action cannot have features: instance control (no instance exists)');
|
|
792
|
+
}
|
|
793
|
+
const parts = [];
|
|
794
|
+
if (opts.static)
|
|
795
|
+
parts.push('static');
|
|
796
|
+
parts.push('action');
|
|
797
|
+
if (opts.features)
|
|
798
|
+
parts.push('( features : instance )');
|
|
799
|
+
parts.push(opts.name);
|
|
800
|
+
if (opts.parameter)
|
|
801
|
+
parts.push(`parameter ${opts.parameter}`);
|
|
802
|
+
if (opts.result) {
|
|
803
|
+
const resultType = opts.resultType ?? '$self';
|
|
804
|
+
if (opts.static && resultType === '$self') {
|
|
805
|
+
throw new Error('buildClauseLine: a static action cannot return $self (no instance); give a typed resultType');
|
|
806
|
+
}
|
|
807
|
+
parts.push(`result ${opts.resultCard ?? '[1]'} ${resultType}`);
|
|
808
|
+
}
|
|
809
|
+
return `${parts.join(' ')};`;
|
|
810
|
+
}
|