diff --git a/AGENTS.md b/AGENTS.md index da56749..485d0e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,11 +41,28 @@ The product is the generated class. It must: - `defaultAgg` is UI5's default aggregation and nothing else, because children of the default aggregation are written without a wrapper tag. Where a drop should land somewhere else, use `dropAgg`. -- Generator changes (`src/abap.js`, `src/xml.js`) need all four checks: - `npm run verify` (unit, linter, runtime, browser). +- Generator changes (`src/abap.js`, `src/xml.js`, `src/clas.js`) need all + four checks: `npm run verify` (unit, linter, runtime, browser). +- An imported class (`src/clas.js`) is patched, not regenerated: only the + view chain is rewritten, every other line stays the author's. What the + editor cannot represent is kept as raw ABAP on its node or listed in the + import report - never dropped silently. Every starter's class must + round-trip byte-identically (`test/clas.test.mjs`). +- The class comes in two releases (`design.release`): 7.40 and later, and + 7.02. A generator change keeps the 7.02 class free of what 7.02 lacks + (inline declarations, string templates, constructor and table + expressions); `npm test` runs abaplint at v702 on it (`abaplint-702.jsonc`, + the rules of abap2UI5's own 7.02 check), `npm run verify:abap` runs both. +- A starter (`src/starters.js`) uses only controls and properties of + `src/catalog.js`; every starter is a test design, rendered on 1.71 too. - `test/builder.mjs` models `z2ui5_cl_ui5_view_builder`. When the builder's semantics change upstream, change the model, and let `verify:abap` decide who is right. +- A UI5 on another host runs with the editor's rights: `?ui5=` from a + link takes only this server, localhost and SAP's CDNs (`isLinkableUi5( )` + in `src/ui/store.js`, the same list in `preview.html`, which takes any + host only when the editor frames it). Any other host comes from the UI5 + field, typed by the user. Never widen the list for a link. - The preview module (`src/preview/init.js`) runs inside UI5 1.71: ES5 style, `sap.ui.define`, no API newer than 1.71. @@ -56,6 +73,7 @@ The product is the generated class. It must: | `npm run serve` | The designer on http://127.0.0.1:8080/ | | `npm test` | Unit tests (catalogue vs. 1.71, class format, chain vs. builder model, import) | | `npm run check:abap` | abap2UI5 linter over the generated classes | +| `npm run check:abap702` | abaplint at v702 over the generated 7.02 classes (also part of `npm test`) | | `npm run verify:abap` | Transpile and run the generated classes on abap2UI5 | | `npm run test:e2e` | The designer in Chromium, UI5 current and 1.71 | | `npm run verify` | All of the above | diff --git a/README.md b/README.md index c66c698..35cc6bb 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,39 @@ From there it is your code: Also in the editor: -- **Starters**: a blank page, a form, a table with search, and tabs. +- **Starters**: a blank page, a form, a table with search, tabs, and the + floorplans: a list report (filter fields and a search over a table), an + object page (header, a form section and an items section), a worklist (a + table with a status column and a row press) and master-detail (a list + next to the details of the selected entry, in two columns). - **Any UI5 control** by its class name (`sap.m.Wizard`, a library of your own): the field at the end of *Controls*, or Ctrl+K. The canvas asks its UI5 for the control's properties, aggregations and events, and from then on it works like one of the palette's. Nothing has checked it against 1.71, and *Checks* says so. +- **Open ABAP class** reads an existing abap2UI5 app class: paste it, open + a `.clas.abap` file, or link it with `?src=` (the playground's + parameter; raw.githubusercontent.com, a github.com file page, or a gist) + or `?class=&repo=[&ref=][&path=]`. + The `z2ui5_cl_ui5_view_builder` chain becomes the design, the public + attributes the data, `client->_event( )` the event names. Export writes + the chain back into that class and leaves every other line as it was; + new attributes go to the end of the public section, new events get a + `WHEN` branch in `on_event`. A view attribute the editor cannot edit + (`client->check_app_prev_stack( )`, a `COND #( )`, an `id`) is kept as + ABAP on its control and written back unchanged; the dialog after the + import lists it, and lists what could not be imported at all (a public + attribute of a type the *Data* tab has not, attributes of the View + element). A class that builds no view, or more than one, is refused + with the reason. - **Import XML** brings in an existing UI5 XML view. Controls the palette does not know are kept as they are. +- **Open in Playground** opens the class in the + [abap2UI5 playground](https://abap2ui5.github.io/playground/), in a share + link of the playground's own format; `?playground=` points it at + another deployment. **Open in VS Code** hands the class to the abap2UI5 + extension (`vscode://abap2ui5.abap2ui5/open`), which opens it as an + unsaved `.clas.abap` and offers to run it. - **Save / Open** stores the design as JSON so you can continue later. The design is also kept in the browser between visits; what New, Open or Import replaced is under *Recent designs*. **Copy share link** puts the @@ -110,21 +135,45 @@ Also in the editor: - An abap2UI5 release that ships `z2ui5_cl_ui5_view_builder`. The class is verified against **abap2UI5 1.147.0**. - ABAP 7.40 or later, or ABAP Cloud. The class uses inline declarations and - `VALUE #( )`, like every abap2UI5 sample. + `VALUE #( )`, like every abap2UI5 sample. For an older system, choose + **ABAP 7.02** next to the class name: the class then declares its + variables at the top of a method, fills its sample rows with `APPEND`, + and writes a line break as `cl_abap_char_utilities=>newline` joined with + `&&` - no inline declarations, string templates or constructor + expressions. The choice is saved with the design. - Every control and property the palette offers exists in **UI5 1.71**: 121 controls of sap.m, sap.ui.core, sap.ui.layout, sap.ui.unified, sap.ui.table, sap.f and sap.uxap. A control inserted by class name is whatever the canvas's UI5 says it is. +## Deploying to your system + +The editor does not write to an SAP system, and neither does anything else +in the abap2UI5 tool set today: the MCP server's system mode +(`A2UI5_MCP_SYSTEM_URL`, see +[mcp-server](https://github.com/abap2UI5/mcp-server#system-mode-operating-apps-on-a-real-sap-system)) +lists, starts and operates apps that are already on the system +(`system_status`, `app_list`, `app_start`, `app_describe`, `app_act`), and +its `deploy_app` writes into a local sandbox only. To get a class onto a +system: + +1. *Export* the ZIP and pull it with abapGit (*New Offline* → *Import + ZIP*), or paste the `.clas.abap` into a new class in ADT or SE24 and + activate it. +2. Optionally, check it from an MCP client with the system mode switched + on: `system_status` first (does the logon work), then `app_start` with + the class name - the agent snapshot shows the view the class built. + `app_act` runs events for real, as the configured user. + ## What is checked, and how Each claim above is backed by a check that runs in CI: | Check | What it proves | |---|---| -| `npm test` | The palette against the **real metadata of UI5 1.71.80** (`test/ui5-1.71.json`): every property, enum value, aggregation, default aggregation and event exists. The class file format (no BOM, LF, max. 255 characters per line, no trailing blanks) and the `.clas.xml` format abapGit writes. The generated chain, replayed by a model of `z2ui5_cl_ui5_view_builder`, builds exactly the editor's view. An imported view comes back unchanged. | +| `npm test` | The palette against the **real metadata of UI5 1.71.80** (`test/ui5-1.71.json`): every property, enum value, aggregation, default aggregation and event exists. The class file format (no BOM, LF, max. 255 characters per line, no trailing blanks) and the `.clas.xml` format abapGit writes. The generated chain, replayed by a model of `z2ui5_cl_ui5_view_builder`, builds exactly the editor's view. An imported view comes back unchanged. The 7.02 classes contain none of the constructs 7.02 lacks, and pass **abaplint at syntax version 7.02** with the rules of abap2UI5's own 7.02 check (`abaplint-702.jsonc`, also `npm run check:abap702`). | | `npm run check:abap` | The [abap2UI5 linter](https://abap2ui5.github.io/linter/) on the generated classes: controls, properties, enum values and icons against 1.71, plus the `chain-house-layout` rule. | -| `npm run verify:abap` | Every starter and a kitchen sink with every control of the palette is **transpiled against abap2UI5** (`@abap2ui5/node-runtime`, type-checked) and **run on it**. The view and the model of the first roundtrip have to equal the editor's, attribute for attribute and value for value. Every event has to reach `on_event`. | +| `npm run verify:abap` | Every starter and a kitchen sink with every control of the palette, each as a 7.40 and as a 7.02 class, is **transpiled against abap2UI5** (`@abap2ui5/node-runtime`, type-checked) and **run on it**. The view and the model of the first roundtrip have to equal the editor's, attribute for attribute and value for value. Every event has to reach `on_event`. | | `npm run test:e2e` | The editor in Chromium: drag and drop, selection on canvas, outline and code, the control toolbar, the command palette, building blocks, properties, binding, events, undo, import, ZIP download. Every design renders on the current UI5 **and on 1.71**. | ## Development @@ -150,6 +199,43 @@ npm run test:e2e:local # the browser tests on them UI5_RESOURCES=.ui5/1.144.0 npm run serve # the editor with ?ui5=local ``` +### A UI5 of your own + +The canvas can load UI5 from any URL instead of the CDN: a UI5 on a web +server of your own, or a `resources/` tree on disk. Enter the URL in the UI5 field above the canvas, or open the editor +with `?ui5=`. The URL is the folder that holds `resources/` +(`https://host/ui5/` for `https://host/ui5/resources/sap-ui-core.js`); a +URL ending in `/resources` works too, and so does a path on the editor's +own server (`/…` or `./…`). Empty goes back to the CDN. + +``` +https://editor.example.com/index.html?ui5=http://127.0.0.1:8081/ +``` + +A UI5 runs its scripts with the editor's rights, so a link may only +name a UI5 on the editor's own server, on `localhost` / `127.0.0.1`, or +on SAP's UI5 CDNs (`sdk.openui5.org`, `ui5.sap.com`). A link that names +any other host is not followed; the editor says so, and the URL works +once you type it into the UI5 field yourself. + +A tree on disk needs a web server that sends +`Access-Control-Allow-Origin` (UI5 loads its modules and themes with +requests the browser checks), for example `npx http-server -p 8081 --cors` +in the folder above `resources/`, and `?ui5=http://127.0.0.1:8081/`. A +release before 1.102 has no Horizon theme; the canvas switches to Quartz +once UI5 has said which release it is. A built UI5 (as the CDN serves +it) brings its compiled themes; a tree from the npm packages +(`npm run ui5:trees`) has the theme sources only, and the canvas shows the +controls unthemed. + +The browser tests take the same URLs, one for the current release and +one for 1.71 - and `E2E_PORT` when another checkout runs its tests on the +default port at the same time: + +```bash +UI5_URL=http://127.0.0.1:8081/1.144.0/ UI5_URL_171=http://127.0.0.1:8081/1.71.80/ npm run test:e2e +``` + `npm run ui5:metadata` regenerates `test/ui5-1.71.json` from a real UI5 runtime. Run it when the palette gains a control. diff --git a/abaplint-702.jsonc b/abaplint-702.jsonc new file mode 100644 index 0000000..087958c --- /dev/null +++ b/abaplint-702.jsonc @@ -0,0 +1,34 @@ +{ + // abaplint on the classes the designer generates for ABAP 7.02 + // (npm run check:abap702 writes them to .verify/abap first): the + // syntax version and the rules of abap2UI5's own 7.02 gate + // (.github/abaplint/abap_702.jsonc in abap2UI5/abap2UI5), against the + // downported framework that @abap2ui5/node-runtime ships. + "global": { + "files": "/.verify/abap/**/*_702.clas.abap" + }, + "dependencies": [ + { "folder": "/node_modules/@abap2ui5/node-runtime/downport", "files": "/**/*.abap" }, + { "folder": "/test/abaplint-deps", "files": "/**/*.abap" } + ], + "syntax": { + "version": "v702", + "errorNamespace": "." + }, + "rules": { + "downport": true, + "begin_end_names": true, + "check_ddic": true, + "check_include": true, + "check_syntax": true, + "global_class": true, + "definitions_top": true, + "implement_methods": true, + "method_implemented_twice": true, + "parser_error": true, + "superclass_final": true, + "unknown_types": true, + "xml_bom": true, + "xml_consistency": true + } +} diff --git a/index.html b/index.html index ca0b0c6..a4a521c 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,10 @@ +
@@ -27,6 +31,7 @@ +
@@ -42,6 +47,8 @@ + + @@ -97,7 +104,7 @@ - +
@@ -130,7 +137,7 @@
- + diff --git a/package-lock.json b/package-lock.json index 38221cd..420547e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "devDependencies": { "@abap2ui5/linter": "0.8.5", "@abap2ui5/node-runtime": "1.147.0", + "@abaplint/cli": "2.120.80", "@abaplint/transpiler-cli": "2.14.3", "@playwright/test": "1.56.1", "express": "5.3.0" @@ -72,6 +73,22 @@ } } }, + "node_modules/@abaplint/cli": { + "version": "2.120.80", + "resolved": "https://registry.npmjs.org/@abaplint/cli/-/cli-2.120.80.tgz", + "integrity": "sha512-+ltmnshclzsMFDljeGw+w6du90Vun8m+roY4cY+EALoYOd7q24n4kv7znyzJIN56tIV32rpROrQJZ+JBsH1q5A==", + "dev": true, + "license": "MIT", + "bin": { + "abaplint": "abaplint" + }, + "engines": { + "node": ">=22.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/larshp" + } + }, "node_modules/@abaplint/database-sqlite": { "version": "2.13.83", "resolved": "https://registry.npmjs.org/@abaplint/database-sqlite/-/database-sqlite-2.13.83.tgz", diff --git a/package.json b/package.json index 371467a..a23070e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "serve": "node tools/serve.mjs 8080", "test": "node --test test/*.test.mjs", "check:abap": "node tools/gen-classes.mjs && abap2ui5lint --config abap2ui5lint.jsonc --no-render", + "check:abap702": "node tools/gen-classes.mjs && abaplint abaplint-702.jsonc", "verify:abap": "node tools/verify-abap.mjs", "test:e2e": "playwright test", "ui5:metadata": "node tools/ui5-metadata.mjs 1.71.80", @@ -21,6 +22,7 @@ "devDependencies": { "@abap2ui5/linter": "0.8.5", "@abap2ui5/node-runtime": "1.147.0", + "@abaplint/cli": "2.120.80", "@abaplint/transpiler-cli": "2.14.3", "@playwright/test": "1.56.1", "express": "5.3.0" diff --git a/playwright.config.mjs b/playwright.config.mjs index 50f877d..dc2f043 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -1,13 +1,17 @@ // Browser tests of the designer (test/e2e). Two legs: the current UI5 and // 1.71, the oldest release abap2UI5 supports - every design has to render // in both. Both boot pinned releases from the OpenUI5 CDN; where the CDN -// is out of reach, local trees stand in (tools/serve.mjs): +// is out of reach, local trees stand in (tools/serve.mjs), or a UI5 of +// your own, by URL (the folder that holds resources/, as ?ui5=): // UI5_RESOURCES=<1.144 tree> UI5_RESOURCES_171=<1.71 tree> npm run test:e2e +// UI5_URL= UI5_URL_171= npm run test:e2e import { defineConfig, devices } from '@playwright/test'; -const PORT = 4173; -const current = process.env.UI5_RESOURCES ? 'local' : '1.144.0'; -const oldest = process.env.UI5_RESOURCES_171 ? 'local:171' : '1.71.80'; +const env = process.env; +// E2E_PORT: another port, for a second checkout testing at the same time +const PORT = Number(env.E2E_PORT) || 4173; +const current = env.UI5_URL || (env.UI5_RESOURCES ? 'local' : '1.144.0'); +const oldest = env.UI5_URL_171 || (env.UI5_RESOURCES_171 ? 'local:171' : '1.71.80'); export default defineConfig({ testDir: 'test/e2e', diff --git a/preview.html b/preview.html index 62479b4..1853191 100644 --- a/preview.html +++ b/preview.html @@ -23,17 +23,34 @@ diff --git a/src/abap.js b/src/abap.js index 48b439e..e89751a 100644 --- a/src/abap.js +++ b/src/abap.js @@ -16,25 +16,94 @@ import { usedNamespaces, namespaceUri } from './xml.js'; const MAX_LINE = 255; +// ------------------------------------------------------------- the release + +// The ABAP release the class is written for. 7.40 and later (ABAP Cloud +// included) is the syntax of every abap2UI5 sample: inline declarations, +// string templates, VALUE #( ). 7.02 is the oldest release abap2UI5 runs +// on: explicit declarations at the top of a method, && for a text with a +// line break, a row variable appended per sample row, WITH DEFAULT KEY, a +// functional call compared with abap_true. The chain is the same in both - +// method chaining is 7.02. Stored in the design (`release`), so a saved +// design always gives the same class. +export const ABAP_RELEASES = { 740: 'ABAP 7.40 or later, ABAP Cloud', 702: 'ABAP 7.02' }; + +export function releaseOf(design) { + return design && design.release === '702' ? '702' : '740'; +} + // ------------------------------------------------------------- literals -export function abapString(s) { +// a text with a line break or a tab: a string template on 7.40, the +// literal pieces around the constants of cl_abap_char_utilities on 7.02 +const CHAR_CONST = { '\r\n': 'cl_abap_char_utilities=>cr_lf', '\n': 'cl_abap_char_utilities=>newline', '\r': 'cl_abap_char_utilities=>cr_lf(1)', '\t': 'cl_abap_char_utilities=>horizontal_tab' }; + +export function abapString(s, release) { const str = String(s ?? ''); if (/[\n\r\t]/.test(str)) { + if (release === '702') return abapStringPieces(str, Infinity, release).join(' && '); return `|${str.replace(/[\\|{}]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}|`; } return '`' + str.replace(/`/g, '``') + '`'; } // a long literal split into pieces joined with &&, so no line passes 255 -function abapStringPieces(s, width) { +function abapStringPieces(s, width, release) { const str = String(s ?? ''); - if (str.length <= width || /[\n\r\t]/.test(str)) return [abapString(str)]; + if (/[\n\r\t]/.test(str)) { + if (release !== '702') return templatePieces(str, width); + return str.split(/(\r\n|[\n\r\t])/).filter(Boolean).flatMap((part) => (CHAR_CONST[part] ? [CHAR_CONST[part]] : abapStringPieces(part, width))); + } + if (str.length <= width) return [abapString(str)]; const parts = []; for (let i = 0; i < str.length; i += width) parts.push(abapString(str.slice(i, i + width))); return parts; } +// a text with a line break on 7.40: string templates of at most `width` +// characters each, cut between two characters of the text so no escape +// is torn apart +function templatePieces(str, width) { + const parts = []; + let cur = ''; + let len = 2; + for (const ch of str) { + const esc = /[\\|{}\n\r\t]/.test(ch) ? 2 : 1; + if (cur && len + esc > width) { parts.push(abapString(cur)); cur = ''; len = 2; } + cur += ch; + len += esc; + } + if (cur) parts.push(abapString(cur)); + return parts; +} + +// A seed value too long for one line comes out of abapValue( ) as its +// pieces between markers; wrapPieces( ) puts every piece after the first +// on a line of its own, `&&` under the start of the value - wherever the +// value ended up in the line (a row of VALUE #( ), an assignment). +const PIECES = '\u0001'; +const PIECE = '\u0002'; + +function wrapPieces(lines) { + const out = []; + const wrap = (line) => { + const i = line.indexOf(PIECES); + if (i < 0) { out.push(line); return; } + const rest = line.slice(i + 1); + const next = rest.indexOf(PIECES); + const value = next < 0 ? rest : rest.slice(0, next); + const tail = rest.slice(value.length); + const segs = value.split(PIECE); + const pad = ' '.repeat(Math.max(0, i - 3)); + if (segs.length === 1) { wrap(line.slice(0, i) + rest); return; } + out.push(line.slice(0, i) + segs[0]); + segs.slice(1, -1).forEach((seg) => out.push(`${pad}&& ${seg}`)); + wrap(`${pad}&& ${segs[segs.length - 1]}${tail}`); + }; + lines.forEach(wrap); + return out; +} + export function abapType(type) { switch (type) { case 'string': return 'string'; @@ -48,10 +117,13 @@ export function abapType(type) { } // a seed value as an ABAP operand, or null when it is the initial value -export function abapValue(type, v) { +export function abapValue(type, v, release) { if (v === undefined || v === null || v === '') return null; switch (type) { - case 'string': return abapString(v); + case 'string': { + const pieces = abapStringPieces(v, 120, release); + return pieces.length === 1 ? pieces[0] : PIECES + pieces.join(PIECE); + } case 'int': { const n = parseInt(v, 10); return Number.isFinite(n) && n !== 0 ? String(n) : null; @@ -80,18 +152,21 @@ export function fileName(className, ext) { // ------------------------------------------------------------- the view -function attrExpr(value) { +function attrExpr(value, release) { switch (value.m) { case 's': { const s = String(value.v ?? ''); - return { p: /[{}\\]/.test(s) ? 't' : 'v', pieces: abapStringPieces(s, 120) }; + return { p: /[{}\\]/.test(s) ? 't' : 'v', pieces: abapStringPieces(s, 120, release) }; } case 'b': // a tree: the path with the field its children come from - if (value.arr) return { p: 'v', pieces: [`|\\{path:'{ client->_bind( val = ${value.v} path = abap_true ) }',parameters:\\{arrayNames:['${value.arr}']\\}\\}|`] }; + if (value.arr) { + if (release === '702') return { p: 'v', pieces: [`\`{path:'\` && client->_bind( val = ${value.v} path = abap_true ) && \`',parameters:{arrayNames:['${value.arr}']}}\``] }; + return { p: 'v', pieces: [`|\\{path:'{ client->_bind( val = ${value.v} path = abap_true ) }',parameters:\\{arrayNames:['${value.arr}']\\}\\}|`] }; + } return { p: 'v', pieces: [`client->_bind( ${value.v} )`] }; case 'f': return { p: 'v', pieces: [abapString(`{${value.v}}`)] }; - case 'x': return { p: 'v', pieces: abapStringPieces(value.v, 120) }; + case 'x': return { p: 'v', pieces: abapStringPieces(value.v, 120, release) }; default: throw new Error(`unknown value mode ${value.m}`); } } @@ -102,14 +177,16 @@ function eleCall(verb, name, ns) { // The view_display body: the chain lines, without the method frame export function viewChain(design, indent = 4) { + const release = releaseOf(design); const out = []; // { text, kind: 'ele'|'tag'|'a'|'end'|'start', blank, node } const base = indent + 4; let owner = 'root'; // the design node the lines being written belong to - out.push({ text: `${' '.repeat(indent)}DATA(view) = z2ui5_cl_ui5_view_builder=>factory(`, kind: 'start' }); + // on 7.02 the variable is declared at the top of the method (buildClass) + out.push({ text: `${' '.repeat(indent)}${release === '702' ? 'view' : 'DATA(view)'} = z2ui5_cl_ui5_view_builder=>factory(`, kind: 'start' }); // the mvc:View root with its namespace declarations - const viewAttrs = usedNamespaces(design).map((ns) => [ns ? `xmlns:${ns}` : 'xmlns', attrExpr({ m: 's', v: namespaceUri(design, ns) })]); - viewAttrs.push(['displayBlock', attrExpr({ m: 's', v: 'true' })], ['height', attrExpr({ m: 's', v: '100%' })]); + const viewAttrs = usedNamespaces(design).map((ns) => [ns ? `xmlns:${ns}` : 'xmlns', attrExpr({ m: 's', v: namespaceUri(design, ns) }, release)]); + viewAttrs.push(['displayBlock', attrExpr({ m: 's', v: 'true' }, release)], ['height', attrExpr({ m: 's', v: '100%' }, release)]); out.push({ text: `${' '.repeat(base)})->${eleCall('ele', 'View', 'mvc')}`, kind: 'ele' }); pushAttrs(viewAttrs, base + 4); emitChildren([['content', design.root.aggs.content || [], true]], base, true); @@ -145,7 +222,7 @@ export function viewChain(design, indent = 4) { for (const [name, value] of Object.entries(node.props || {})) { if (!value || (value.m === 's' && (value.v === '' || value.v == null))) continue; if ((value.m === 'b' || value.m === 'f' || value.m === 'x') && !value.v) continue; - attrs.push([name, attrExpr(value)]); + attrs.push([name, attrExpr(value, release)]); } for (const [ev, name] of Object.entries(node.events || {})) { if (name) attrs.push([ev, { p: 'v', pieces: [`client->_event( ${abapString(name)} )`] }]); @@ -249,7 +326,7 @@ function nestedRowLines(cells, indent, tail) { if (row.every(([, x]) => typeof x === 'string') && inner.length + 2 + row.map(([a, b]) => `${a} = ${b}`).join(' ').length + 2 + rowTail.length <= MAX_LINE) { lines.push(`${inner}( ${row.map(([a, b]) => `${a} = ${b}`).join(' ')} )${rowTail}`); } else if (row.length) { - lines.push(...nestedRowLines(row, inner, rowTail.slice(2))); + lines.push(...nestedRowLines(row, inner, rowTail)); } else { lines.push(`${inner}( )${rowTail}`); } @@ -287,7 +364,8 @@ function seedTable(attr) { } else if (sameShape && nonEmpty.length > 1) { const cols = nonEmpty[0].length; const widths = []; - for (let i = 0; i < cols; i++) widths[i] = Math.max(...nonEmpty.map((r) => cellText(...r[i]).length)); + // a value in pieces goes onto lines of its own and sets no column + for (let i = 0; i < cols; i++) widths[i] = Math.max(0, ...nonEmpty.map((r) => cellText(...r[i])).filter((t) => !t.includes(PIECES)).map((t) => t.length)); rendered = nonEmpty.map((r) => `( ${r.map((cell, i) => (i < cols - 1 ? cellText(...cell).padEnd(widths[i]) : cellText(...cell))).join(' ')} )`); } else { rendered = nonEmpty.map((r) => `( ${r.map((cell) => cellText(...cell)).join(' ')} )`); @@ -313,6 +391,52 @@ function seedTable(attr) { return lines; } +// 7.02 has no VALUE #( ): one row variable per table (and per table +// field), filled field by field and appended, CLEAR before every row but +// the first - so a field a row leaves out is initial, as in VALUE #( ). +// `names`: the names a row variable must not take (the attributes, client, +// view, the row variables so far). Returns the declarations and the lines. +function seedTable702(attr, names) { + const lines = []; + const decls = []; + const appended = new Set(); + const vars = new Map(); // type name -> row variable + const declare = (type, stem) => { + if (vars.has(type)) return vars.get(type); + let v = `${stem}_row`; + for (let i = 2; names.has(v); i++) v = `${stem}_row_${i}`; + names.add(v); + vars.set(type, v); + decls.push([`DATA ${v}`, `TYPE ${type}.`]); + return v; + }; + const emitRows = (rows, fields, path, target, stem) => { + const v = declare(typeName(attr, path), stem); + const scalar = fields.filter((f) => f.type !== 'table' && (rows || []).some((r) => abapValue(f.type, r[f.name]) !== null)); + const nested = fields.filter((f) => f.type === 'table'); + const w = Math.max(0, ...scalar.map((f) => `${v}-${f.name.toLowerCase()}`.length)); + for (const r of rows || []) { + if (appended.has(v)) lines.push('', ` CLEAR ${v}.`); + for (const f of scalar) { + const val = abapValue(f.type, r[f.name]); + if (val === null) continue; + const head = ` ${`${v}-${f.name.toLowerCase()}`.padEnd(w)} = `; + // a long text in pieces, so no line passes 255 + const [first, ...rest] = f.type === 'string' ? abapStringPieces(r[f.name], 120, '702') : [val]; + lines.push(`${head}${first}${rest.length ? '' : '.'}`); + rest.forEach((piece, i) => lines.push(`${' '.repeat(head.length - 3)}&& ${piece}${i === rest.length - 1 ? '.' : ''}`)); + } + for (const f of nested) { + if ((r[f.name] || []).length) emitRows(r[f.name], f.fields || [], [...path, f.name], `${v}-${f.name.toLowerCase()}`, f.name.toLowerCase()); + } + lines.push(` APPEND ${v} TO ${target}.`); + appended.add(v); + } + }; + emitRows(attr.rows, attr.fields || [], [], attr.name, attr.name.replace(/^[a-z]?t_/, '')); + return { decls, lines }; +} + export function generateClass(design) { return buildClass(design).source; } @@ -326,16 +450,33 @@ export function generateClassMap(design) { function buildClass(design) { const cls = design.className.toLowerCase(); + const release = releaseOf(design); + const old = release === '702'; + const tableKey = old ? 'WITH DEFAULT KEY' : 'WITH EMPTY KEY'; + // 7.02 has no predicative method call: the result is compared + const check = (m) => `client->${m}( )${old ? ' = abap_true' : ''}`; const events = collectEvents(design); const tables = design.attributes.filter((a) => a.type === 'table'); const scalars = design.attributes.filter((a) => a.type !== 'table'); const seed = []; for (const a of scalars) { - const v = abapValue(a.type, a.value); + const v = abapValue(a.type, a.value, release); if (v !== null) seed.push(` ${a.name} = ${v}.`); } - for (const t of tables) seed.push(...seedTable(t)); + const seedDecls = []; + if (old) { + const names = new Set([...design.attributes.map((a) => a.name.toLowerCase()), 'client', 'view', 'me']); + for (const t of tables) { + const { decls, lines } = seedTable702(t, names); + if (!lines.length) continue; + seedDecls.push(...decls); + if (seed.length) seed.push(''); + seed.push(...lines); + } + } else { + for (const t of tables) seed.push(...seedTable(t)); + } const hasInit = seed.length > 0; const hasEvents = events.length > 0; @@ -350,12 +491,12 @@ function buildClass(design) { for (const ty of tableTypes(t)) { L.push(' TYPES:'); L.push(` BEGIN OF ${ty.name},`); - L.push(...aligned(ty.fields.map((f) => [f.name.toLowerCase(), `TYPE ${f.type === 'table' ? `STANDARD TABLE OF ${typeName(t, [...ty.path, f.name])} WITH EMPTY KEY` : abapType(f.type)},`]), 8)); + L.push(...aligned(ty.fields.map((f) => [f.name.toLowerCase(), `TYPE ${f.type === 'table' ? `STANDARD TABLE OF ${typeName(t, [...ty.path, f.name])} ${tableKey}` : abapType(f.type)},`]), 8)); L.push(` END OF ${ty.name}.`); } } const dataRows = [ - ...tables.map((t) => [`DATA ${t.name}`, `TYPE STANDARD TABLE OF ${typeName(t)} WITH EMPTY KEY.`]), + ...tables.map((t) => [`DATA ${t.name}`, `TYPE STANDARD TABLE OF ${typeName(t)} ${tableKey}.`]), ...scalars.map((a) => [`DATA ${a.name}`, `TYPE ${abapType(a.type)}.`]), ]; if (dataRows.length) L.push(...aligned(dataRows, 4)); @@ -376,16 +517,16 @@ function buildClass(design) { L.push(''); L.push(' me->client = client.'); if (hasInit) { - L.push(' IF client->check_on_init( ).'); + L.push(` IF ${check('check_on_init')}.`); L.push(' model_init( ).'); L.push(' view_display( ).'); - L.push(' ELSEIF client->check_on_navigated( ).'); + L.push(` ELSEIF ${check('check_on_navigated')}.`); } else { - L.push(' IF client->check_on_navigated( ).'); + L.push(` IF ${check('check_on_navigated')}.`); } L.push(' view_display( ).'); if (hasEvents) { - L.push(' ELSEIF client->check_on_event( ).'); + L.push(` ELSEIF ${check('check_on_event')}.`); L.push(' on_event( ).'); } L.push(' ENDIF.'); @@ -394,6 +535,10 @@ function buildClass(design) { L.push(''); L.push(' METHOD view_display.'); L.push(''); + if (old) { + L.push(' DATA view TYPE REF TO z2ui5_cl_ui5_view_builder.'); + L.push(''); + } const chain = viewChain(design, 4); const chainStart = L.length; L.push(...chain); @@ -418,7 +563,11 @@ function buildClass(design) { L.push(''); L.push(' METHOD model_init.'); L.push(''); - L.push(...seed); + if (seedDecls.length) { + L.push(...aligned(seedDecls, 4)); + L.push(''); + } + L.push(...wrapPieces(seed)); L.push(''); L.push(' ENDMETHOD.'); } diff --git a/src/app.js b/src/app.js index 898e517..d3b7c0c 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ // module, which renders the panels (src/ui/*.js). import { validate, checkClassName } from './model.js'; import { generateClass, fileName } from './abap.js'; +import { generateClassSource } from './clas.js'; import { toXml } from './xml.js'; import { state, on, loadPrefs, savePrefs, loadDesign, designFromLink, replaceDesign, commit, undo, redo, select, showPanel, labelOfSelection } from './ui/store.js'; import { $, toast, download } from './ui/dom.js'; @@ -21,7 +22,7 @@ import { renderCode, highlightCode, setCode, setView, copyText } from './ui/code import { loadPreview, postPreview, sendPreview } from './ui/preview.js'; import { handleDrop, copySelection, pasteSelection, addControlByName } from './ui/edit.js'; import { openPalette } from './ui/palette.js'; -import { newDialog, importDialog, recentDialog, shareLink, openFile, saveDesign, exportZip } from './ui/files.js'; +import { newDialog, importDialog, openClassDialog, openClassFromUrl, recentDialog, shareLink, openFile, saveDesign, exportZip, openInPlayground, openInVscode } from './ui/files.js'; import { onKey } from './ui/keys.js'; @@ -83,6 +84,7 @@ function renderAll(opts = {}) { if (document.activeElement !== cn) cn.value = d.className; cn.classList.toggle('invalid', !!checkClassName(d.className)); if (document.activeElement !== $('#description')) $('#description').value = d.description || ''; + $('#abapRelease').value = d.release || ''; renderLayers(); // keep the input being typed into: re-rendering its panel would drop it if (!opts.keepFocus || !$('#inspector').contains(document.activeElement)) renderInspector(); @@ -97,6 +99,9 @@ function renderAll(opts = {}) { } +// a UI5 of your own for the canvas: the folder that holds resources/ +const isUi5Url = (v) => /^(https?:\/\/|\.{0,2}\/)[^"'<>\s]*$/.test(v); + function wire() { $('#insertSearch').addEventListener('input', renderInsert); $('#className').addEventListener('input', (e) => { @@ -104,10 +109,14 @@ function wire() { commit((d) => { d.className = v; }, { mergeKey: 'className', keepFocus: true }); }); $('#description').addEventListener('input', (e) => commit((d) => { d.description = e.target.value; }, { mergeKey: 'description', keepFocus: true })); + $('#abapRelease').addEventListener('change', (e) => commit((d) => { if (e.target.value) d.release = e.target.value; else delete d.release; })); wireMenu($('#btnFile'), $('#fileMenu')); wireMenu($('#btnExport'), $('#exportMenu')); $('#btnNew').onclick = newDialog; - $('#btnOpen').onclick = () => openFile('.json,.xml'); + $('#btnOpen').onclick = () => openFile('.json,.xml,.abap'); + $('#btnOpenClass').onclick = openClassDialog; + $('#btnPlayground').onclick = openInPlayground; + $('#btnVscode').onclick = openInVscode; $('#btnSave').onclick = saveDesign; $('#btnImport').onclick = importDialog; $('#btnRecent').onclick = recentDialog; @@ -117,9 +126,9 @@ function wire() { $('#btnCmd').onclick = openPalette; $('#btnZip').onclick = exportZip; $('#btnZip2').onclick = exportZip; - $('#btnCopy').onclick = () => copyText(generateClass(state.design), 'ABAP'); + $('#btnCopy').onclick = () => copyText(generateClassSource(state.design), 'ABAP'); $('#btnCopyXml').onclick = () => copyText(toXml(state.design), 'XML'); - $('#btnDownloadAbap').onclick = () => download(fileName(state.design.className, 'clas.abap'), generateClass(state.design), 'text/plain'); + $('#btnDownloadAbap').onclick = () => download(fileName(state.design.className, 'clas.abap'), generateClassSource(state.design), 'text/plain'); for (const b of document.querySelectorAll('.rail-btn')) b.onclick = () => showPanel(b.dataset.panel); for (const b of document.querySelectorAll('[data-view]')) b.onclick = () => setView(b.dataset.view); for (const b of document.querySelectorAll('[data-code]')) b.onclick = () => setCode(b.dataset.code); @@ -149,7 +158,7 @@ function wire() { ver.value = state.prefs.ui5 && !state.prefs.ui5.startsWith('local') ? state.prefs.ui5 : ''; ver.onchange = () => { const v = ver.value.trim(); - if (v && !/^\d+\.\d+\.\d+$/.test(v)) { ver.classList.add('invalid'); toast('A version like 1.71.80, or empty for the latest'); return; } + if (v && !/^\d+\.\d+\.\d+$/.test(v) && !isUi5Url(v)) { ver.classList.add('invalid'); toast('A version like 1.71.80, the URL of a UI5 of your own, or empty for the latest'); return; } ver.classList.remove('invalid'); state.prefs.ui5 = v; const minor = /^1\.(\d+)\./.exec(v); @@ -194,6 +203,11 @@ renderInsert(); renderAll(); loadPreview(); +// a class to open: ?src= (the playground's parameter) or +// ?class=&repo=[&ref=][&path=] +openClassFromUrl(new URLSearchParams(location.search)); +if (state.refusedUi5) toast(`The link named a UI5 on another host (${state.refusedUi5}); it is not loaded from a link. Type it into the UI5 field to use it.`); + // a shared link: its design replaces the current one (Recent keeps that) if (location.hash.startsWith('#d=')) { designFromLink(location.hash).then((d) => { @@ -203,4 +217,4 @@ if (location.hash.startsWith('#d=')) { } // for the browser tests and the curious: the state and the generators -window.designer = { state, generateClass, toXml, validate, select, commit, handleDrop, openPalette, copySelection, pasteSelection, addControlByName }; +window.designer = { state, generateClass, generateClassSource, toXml, validate, select, commit, handleDrop, openPalette, copySelection, pasteSelection, addControlByName }; diff --git a/src/clas.js b/src/clas.js new file mode 100644 index 0000000..53bd90a --- /dev/null +++ b/src/clas.js @@ -0,0 +1,953 @@ +// An abap2UI5 app class -> design (import), and the design -> that class +// again (export): the regenerated view_display chain written back into the +// class it came from, everything else - on_event branches, model_init, any +// method of the author's own - exactly as it was. +// +// The import reads the class in three places: +// - the PUBLIC SECTION: every DATA attribute whose type the editor has +// (string, i, p, abap_bool, d, t, a table of a structure of those) is an +// attribute of the design; the structure types come from the TYPES +// - the seeds: `name = literal.` and `t_rows = VALUE #( ( ... ) ).` in +// model_init (or, without one, anywhere in the class) are the sample data +// - the view: the z2ui5_cl_ui5_view_builder chain, read by an interpreter +// of the builder's four calls (ele/tag/a/end) - the one-statement shape +// the editor writes and the split shape of the samples (`DATA(page) = +// view->ele( ... ).` then `page->tag( ... )`) alike. Every attribute +// keeps the ABAP that produced it: `client->_bind( name )` is a binding +// of that attribute, `client->_event( \`SAVE\` )` the event SAVE, a +// literal (or `&&` of literals) its text. The tree is rendered as an XML +// view and goes through fromXml( ), so an import of a class resolves +// bindings, keeps unknown controls and reports exactly like an import of +// a view does. +// What the editor cannot represent is never dropped silently. An attribute +// whose value is ABAP the editor has no field for (a method call, a +// variable, a template with ABAP in it), an `id` and a prefixed attribute +// (`core:require`) are kept with their control as raw ABAP +// (`design.abap.raw`, by node id) and written back into the regenerated +// chain - unless the editor sets that attribute itself, or the control is +// deleted. A public attribute of a type the data panel has no editor for +// stays in the class untouched. All of it is in the report. +// +// The export patches rather than generates: the design remembers the source +// and where its chain was (`design.abap`), the chain is written again in the +// house layout over exactly that range, attributes the editor added are +// declared at the end of the PUBLIC SECTION, and an event the editor named +// gets its WHEN branch in the CASE of on_event. A design without `abap` is +// generated as always. + +import { viewChain, generateClassMap, abapGitFiles, abapType, abapString, fileName } from './abap.js'; +import { fromXml, importReport, escapeXml, escapeBindingLiteral, isSetValue } from './xml.js'; +import { collectEvents, tableTypes, typeName, clone, walk } from './model.js'; + +// ------------------------------------------------------------- scanning + +// The source with its comments blanked to spaces and its literals to \x01, +// so a full stop or a parenthesis inside either counts for nothing. The +// three literal forms (' and ` with a doubled quote for one, |...| with +// backslash escapes and braces holding ABAP) and both comment forms. +export function maskSource(source) { + const mask = source.split(''); + const blank = (from, to, fill) => { for (let i = from; i < to; i++) if (mask[i] !== '\n') mask[i] = fill; }; + let at = 0; + while (at < source.length) { + const ch = source[at]; + if (ch === '"' || (ch === '*' && (at === 0 || source[at - 1] === '\n'))) { + const eol = source.indexOf('\n', at); + const end = eol === -1 ? source.length : eol; + blank(at, end, ' '); + at = end; + } else if (ch === "'" || ch === '`') { + const end = closingQuote(source, at, ch); + blank(at, end, '\x01'); + at = end; + } else if (ch === '|') { + const end = closingTemplate(source, at); + blank(at, end, '\x01'); + at = end; + } else { + at += 1; + } + } + return mask.join(''); +} + +function closingQuote(source, from, quote) { + let at = from + 1; + while (at < source.length) { + if (source[at] === '\n') return at; + if (source[at] === quote) { + if (source[at + 1] === quote) { at += 2; continue; } + return at + 1; + } + at += 1; + } + return source.length; +} + +function closingTemplate(source, from) { + const depths = [0]; + let at = from + 1; + while (at < source.length) { + const ch = source[at]; + const top = depths.length - 1; + const depth = depths[top]; + if (ch === '\n' && depth === 0) { depths.pop(); if (!depths.length) return at; continue; } + if (ch === '\\' && depth === 0) { at += 2; continue; } + if (depth > 0 && (ch === "'" || ch === '`')) { at = closingQuote(source, at, ch); continue; } + if (ch === '{') depths[top] += 1; + else if (ch === '}') depths[top] = Math.max(0, depth - 1); + else if (ch === '|' && depth === 0) { depths.pop(); if (!depths.length) return at + 1; } else if (ch === '|') depths.push(0); + at += 1; + } + return source.length; +} + +// the statements as ranges [start, end) on the mask, end past the full stop +function statementsOf(mask, from = 0, to = mask.length) { + const out = []; + let start = from; + for (let at = from; at < to; at++) { + if (mask[at] !== '.') continue; + if (mask.slice(start, at).trim() !== '') out.push({ start, end: at + 1 }); + start = at + 1; + } + return out; +} + +function closingParen(mask, from, to = mask.length) { + let depth = 0; + for (let at = from; at < to; at++) { + if (mask[at] === '(') depth += 1; + else if (mask[at] === ')') { depth -= 1; if (depth === 0) return at; } + } + return -1; +} + +// the constants of cl_abap_char_utilities the 7.02 class joins its line +// breaks and tabs with (src/abap.js, CHAR_CONST) +const CHAR_UTILITIES = { + 'cl_abap_char_utilities=>cr_lf': '\r\n', + 'cl_abap_char_utilities=>newline': '\n', + 'cl_abap_char_utilities=>cr_lf(1)': '\r', + 'cl_abap_char_utilities=>horizontal_tab': '\t', +}; +const TEMPLATE_ESCAPES = { n: '\n', r: '\r', t: '\t', '\\': '\\', '|': '|', '{': '{', '}': '}' }; + +// the text of a literal token ('...', `...`), of a string template with +// no embedded expression (|a\nb|, the way 7.40 writes a line break), or of +// a char constant of cl_abap_char_utilities - undefined for anything else +function literalText(token) { + const t = token.trim(); + const constant = CHAR_UTILITIES[t.toLowerCase()]; + if (constant !== undefined) return constant; + const q = t[0]; + if (t.length < 2 || t[t.length - 1] !== q) return undefined; + if (q === '|') return plainTemplateText(t.slice(1, -1)); + if (q !== "'" && q !== '`') return undefined; + // one literal only: a lone quote inside means two literals and + // what stands between them (`a` && `b`), which concatText( ) reads + const inner = t.slice(1, -1).split(q + q); + if (inner.some((part) => part.includes(q))) return undefined; + return inner.join(q); +} + +// the body of a string template as text, or undefined when it embeds an +// expression ({ ... }) or holds an escape the generator does not write +function plainTemplateText(body) { + let out = ''; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === '{' || ch === '}' || ch === '|') return undefined; + if (ch !== '\\') { out += ch; continue; } + const esc = TEMPLATE_ESCAPES[body[i + 1]]; + if (esc === undefined) return undefined; + out += esc; + i += 1; + } + return out; +} + +// an ABAP operand on one line: every run of blanks outside a literal (and +// a comment, which the mask blanks) one space - what a raw attribute is +// written back as, inside the one-call-per-line chain +function oneLine(abap) { + const t = abap.trim(); + const mask = maskSource(t); + let out = ''; + for (let i = 0; i < t.length; i++) { + if (/\s/.test(mask[i]) && mask[i] !== '\x01') { if (!out.endsWith(' ')) out += ' '; } else out += t[i]; + } + return out.trim(); +} + +// a literal, or literals joined with && (the way a long text is written), +// as one string - undefined for anything else +function concatText(mask, source, from, to) { + const parts = []; + let at = from; + while (at < to) { + const seg = mask.indexOf('&&', at); + const end = seg < 0 || seg >= to ? to : seg; + const text = literalText(source.slice(at, end)); + if (text === undefined) return undefined; + parts.push(text); + at = end + 2; + } + return parts.join(''); +} + +// ------------------------------------------------------------- the view chain + +const FACTORY = 'z2ui5_cl_ui5_view_builder=>factory'; + +// The chain of a class as a tree of { name, ns, attrs: [{ name, raw, key }], +// children } with the range of the source it occupies. `{ ok: false, why }` +// when there is none, or when it is more than the four builder calls. +export function readViewChain(source) { + const mask = maskSource(source); + const statements = statementsOf(mask); + const buildsView = (s) => mask.slice(s.start, s.end).toLowerCase().includes(FACTORY); + const first = statements.findIndex(buildsView); + if (first < 0) return { ok: false, why: 'Nothing here builds a view with z2ui5_cl_ui5_view_builder.' }; + if (statements.some((s, i) => i > first && buildsView(s))) return { ok: false, why: 'This class builds more than one view - the editor edits one.' }; + + const root = { name: '', ns: '', attrs: [], children: [], parent: null }; + const vars = new Map(); + let regionStart = null; + let regionEnd = null; + let variable = null; + let indent = 0; + for (let i = first; i < statements.length; i++) { + const st = statements[i]; + const text = mask.slice(st.start, st.end); + if (i > first && /\bstringify\s*\(/i.test(text)) break; + const m = /^(\s*)((?:DATA\(\s*(\w+)\s*\)|(\w+))\s*=\s*)?(z2ui5_cl_ui5_view_builder=>factory|[a-z_]\w*)\s*(?=\(|->)/i.exec(text); + if (!m) { + if (i === first) return { ok: false, why: 'The chain does not start with a plain assignment.' }; + break; + } + const target = m[5].toLowerCase(); + const isFactory = target === FACTORY; + if (!isFactory && !vars.has(target)) { + if (i === first) return { ok: false, why: 'The chain does not start with the factory.' }; + break; + } + const declared = (m[3] || m[4] || '').toLowerCase(); + if (i === first) { + regionStart = st.start + m[1].length; + variable = declared || null; + const lineStart = source.lastIndexOf('\n', regionStart - 1) + 1; + if (source.slice(lineStart, regionStart).trim() === '') { indent = regionStart - lineStart; regionStart = lineStart; } + } + let cursor = isFactory ? root : vars.get(target); + let at = st.start + m[0].length; + const to = st.end - 1; + const skip = () => { while (at < to && /\s/.test(mask[at])) at += 1; }; + skip(); + if (isFactory) { + if (mask[at] !== '(') return { ok: false, why: 'factory( ) is called without parentheses.' }; + const close = closingParen(mask, at, st.end); + if (close < 0) return { ok: false, why: 'A parenthesis in the chain is never closed.' }; + if (mask.slice(at + 1, close).trim() !== '') return { ok: false, why: 'factory( ) takes no arguments.' }; + at = close + 1; + } + for (;;) { + skip(); + if (at >= to) break; + const call = /^->\s*(\w+)\s*(?=\()/.exec(mask.slice(at, to + 1)); + if (!call) return { ok: false, why: `The chain has a call this cannot read: ${source.slice(at, Math.min(to, at + 30)).trim()}` }; + at += call[0].length; + const close = closingParen(mask, at, st.end); + if (close < 0) return { ok: false, why: 'A parenthesis in the chain is never closed.' }; + const applied = apply(cursor, call[1].toLowerCase(), readArgs(mask, source, at + 1, close)); + if (!applied.ok) return applied; + cursor = applied.cursor; + at = close + 1; + } + if (declared) vars.set(declared, cursor); + regionEnd = st.end; + } + if (root.children.length !== 1) return { ok: false, why: 'The chain does not build exactly one root element.' }; + return { ok: true, root: root.children[0], start: regionStart, end: regionEnd, indent, variable: variable || 'view' }; +} + +// the named arguments (n, ns, v, b, t) of one call, or the positional one +function readArgs(mask, source, from, to) { + const marks = []; + let depth = 0; + for (let at = from; at < to; at++) { + const ch = mask[at]; + if (ch === '(' || ch === '[') depth += 1; + else if (ch === ')' || ch === ']') depth -= 1; + else if (depth === 0) { + const m = /^(ns|n|v|b|t)\s*=(?!=)/i.exec(mask.slice(at, to)); + if (m && (at === from || /[\s(]/.test(mask[at - 1]))) { + marks.push({ key: m[1].toLowerCase(), from: at, valueAt: at + m[0].length }); + at += m[0].length - 1; + } + } + } + const args = {}; + const value = (a, b) => { + const masked = mask.slice(a, b); + const s = a + (masked.length - masked.trimStart().length); + const e = a + masked.trimEnd().length; + return { raw: source.slice(s, e), text: concatText(mask, source, s, e) }; + }; + if (!marks.length) { + if (mask.slice(from, to).trim() !== '') args.positional = value(from, to); + return args; + } + marks.forEach((mark, i) => { args[mark.key] = value(mark.valueAt, i + 1 < marks.length ? marks[i + 1].from : to); }); + return args; +} + +// One builder call against the tree, the class's own semantics: a( ) lands +// on the last child when there is one, else on the node itself +function apply(cursor, method, args) { + if (method === 'ele' || method === 'tag') { + const name = (args.n || args.positional || {}).text; + if (name === undefined) return { ok: false, why: 'An element is added under a name that is not a literal.' }; + if (args.ns && args.ns.text === undefined) return { ok: false, why: 'A namespace prefix is not a literal.' }; + const node = { name, ns: args.ns ? args.ns.text : '', attrs: [], children: [], parent: cursor }; + cursor.children.push(node); + return { ok: true, cursor: method === 'ele' ? node : cursor }; + } + if (method === 'a') { + const target = cursor.children.length ? cursor.children[cursor.children.length - 1] : cursor; + if (target.name === '') return { ok: false, why: 'An attribute is set before any element was added.' }; + const name = args.n && args.n.text; + if (name === undefined) return { ok: false, why: 'An attribute is set under a name that is not a literal.' }; + const key = args.v ? 'v' : args.b ? 'b' : args.t ? 't' : null; + if (!key) return { ok: false, why: `\`${name}\` is set without a value.` }; + target.attrs.push({ name, key, raw: args[key].raw, text: args[key].text }); + return { ok: true, cursor }; + } + if (method === 'end') { + if (!cursor.parent) return { ok: false, why: 'The chain ascends past its own root.' }; + return { ok: true, cursor: cursor.parent }; + } + return { ok: false, why: `${method}( ) is not one of the builder's calls.` }; +} + +// ------------------------------------------------------------- attribute values + +// What one attribute of the chain means in the view, from the ABAP that +// produced it: { xml } for a value the XML view can carry (bindings in the +// shape abap2UI5 writes, so fromXml( ) resolves them), { event } for a +// client->_event( ), null when the ABAP is beyond the editor +function attrValue(attr) { + const raw = attr.raw.trim(); + if (attr.key === 'b') { + if (/^abap_true$/i.test(raw)) return { xml: 'true' }; + if (/^(abap_false|space|' ?'|` ?`)$/i.test(raw)) return { xml: 'false' }; + return null; + } + if (attr.text !== undefined) return { xml: attr.key === 't' ? escapeBindingLiteral(attr.text) : attr.text }; + const ev = /^client->_event\(\s*(?:val\s*=\s*)?([`'][A-Za-z0-9_]+[`'])\s*\)$/.exec(raw); + if (ev) return { event: literalText(ev[1]) }; + const bind = bindPath(raw); + if (bind) return { xml: `{${bind}}` }; + if (raw[0] === '|' && raw[raw.length - 1] === '|') return templateText(raw.slice(1, -1)); + return concatBindText(raw); +} + +// the 7.02 form of the tree binding: literals and bare bind paths joined +// with && (`{path:'` && client->_bind( ... ) && `',...}`) as the text it +// renders to; null when a piece is anything else +function concatBindText(raw) { + const mask = maskSource(raw); + if (mask.indexOf('&&') < 0) return null; + let out = ''; + let at = 0; + while (at <= raw.length) { + const seg = mask.indexOf('&&', at); + const end = seg < 0 ? raw.length : seg; + const piece = raw.slice(at, end).trim(); + const text = literalText(piece); + if (text !== undefined) out += text; + else { + const p = bindPath(piece, true); + if (!p) return null; + out += p; + } + if (seg < 0) break; + at = seg + 2; + } + return { xml: out }; +} + +// A string template whose embedded expressions are all bare bind paths - +// the tree binding the editor writes - as the text it renders to; null +// when an expression is anything else +function templateText(body) { + let out = ''; + let at = 0; + while (at < body.length) { + const ch = body[at]; + if (ch === '\\') { out += body[at + 1] === 'n' ? '\n' : body[at + 1] === 't' ? '\t' : body[at + 1] === 'r' ? '\r' : body[at + 1]; at += 2; continue; } + if (ch === '{') { + let depth = 1; + let end = at + 1; + while (end < body.length && depth) { if (body[end] === '{') depth += 1; else if (body[end] === '}') depth -= 1; end += 1; } + const p = bindPath(body.slice(at + 1, end - 1).trim(), true); + if (!p) return null; + out += p; + at = end; + continue; + } + out += ch; + at += 1; + } + return { xml: out }; +} + +// client->_bind( name ) -> /NAME, client->_bind( val = s-f path = abap_true ) +// -> /S/F when `bare` is allowed; null for every other call +function bindPath(expr, bare = false) { + const m = /^client->_bind(?:_edit)?\(\s*(.*?)\s*\)$/s.exec(expr); + if (!m) return null; + const inner = m[1]; + let name = null; + let isBare = false; + if (/^(me->)?\w+(-\w+)*$/.test(inner)) name = inner; + else { + const named = /^val\s*=\s*((?:me->)?\w+(?:-\w+)*)(\s+path\s*=\s*abap_true)?$/i.exec(inner); + if (!named) return null; + name = named[1]; + isBare = !!named[2]; + } + if (isBare !== bare) return null; + return '/' + name.replace(/^me->/i, '').toUpperCase().replace(/-/g, '/'); +} + +// the attributes of the View element the generator writes itself +const VIEW_OWN = /^(xmlns(:\w+)?|displayBlock|height)$/; +const VIEW_FIXED = { displayBlock: 'true', height: '100%' }; + +// the chain as an XML view: literals escaped, bindings as abap2UI5 writes +// them, events as the preview flavour of toXml( ) writes them (so the same +// reader takes both). What has no XML is collected in `raw` - one entry +// per element that has some, its index in a RAW_MARK attribute fromXml( ) +// skips - and, on the View itself, which the generator writes alone, in +// `dropped` +const RAW_MARK = 'le:raw'; + +function chainToXml(root, raw, dropped) { + const lines = []; + emit(root, 0, ''); + return lines.join('\n'); + + function emit(node, depth, path) { + const tag = node.ns ? `${node.ns}:${node.name}` : node.name; + const here = `${path}/${tag}`; + const attrs = []; + const kept = []; + for (const a of node.attrs) { + if (depth === 0) { + // the View: its namespaces are what fromXml( ) needs, the rest is the generator's + const own = VIEW_OWN.test(a.name) && a.text !== undefined && (VIEW_FIXED[a.name] === undefined || VIEW_FIXED[a.name] === a.text); + if (own) attrs.push(`${a.name}="${escapeXml(a.text)}"`); + else dropped.push({ control: tag, attr: a.name, abap: oneLine(a.raw) }); + continue; + } + const v = attrValue(a); + if (!v || a.name === 'id' || a.name.includes(':')) { kept.push({ name: a.name, key: a.key, abap: oneLine(a.raw) }); continue; } + if (v.event !== undefined) attrs.push(`${a.name}=".onEvent('${v.event}')"`); + else attrs.push(`${a.name}="${escapeXml(v.xml)}"`); + } + if (kept.length) { attrs.push(`${RAW_MARK}="${raw.length}"`); raw.push({ control: tag, attrs: kept }); } + const pad = ' '.repeat(depth); + const open = `${pad}<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}`; + if (!node.children.length) { lines.push(`${open}/>`); return; } + lines.push(`${open}>`); + for (const ch of node.children) emit(ch, depth + 1, here); + lines.push(`${pad}`); + } +} + +// ------------------------------------------------------------- the data + +const SCALAR = [ + [/^string$/i, 'string'], [/^c(\s+LENGTH\s+\d+)?$/i, 'string'], [/^n(\s+LENGTH\s+\d+)?$/i, 'string'], + [/^i$/i, 'int'], [/^int[48]$/i, 'int'], + [/^p(\s+LENGTH\s+\d+)?(\s+DECIMALS\s+\d+)?$/i, 'decimal'], [/^f$/i, 'decimal'], [/^decfloat(16|34)$/i, 'decimal'], + [/^(abap_bool|abap_boolean|xsdboolean|xsd_boolean)$/i, 'bool'], + [/^d$/i, 'date'], [/^t$/i, 'time'], +]; + +function scalarType(abap) { + const t = abap.trim(); + for (const [re, type] of SCALAR) if (re.test(t)) return type; + return null; +} + +// the elements of `DATA a TYPE x.` and `DATA: a TYPE x, b TYPE y.` alike +function elementsOf(text, keyword) { + const m = new RegExp(`^\\s*${keyword}\\s*:?\\s*([\\s\\S]*)\\.$`, 'i').exec(text); + if (!m) return null; + return m[1].split(',').map((s) => s.trim()).filter(Boolean); +} + +// The attributes of the PUBLIC SECTION, with the structures the TYPES +// declare; what the editor cannot type is listed in `skipped` +export function readPublicSection(source) { + const mask = maskSource(source); + const def = /\bCLASS\s+(\S+)\s+DEFINITION\b[\s\S]*?\.\s*/i.exec(mask); + if (!def) return null; + const pub = /\bPUBLIC\s+SECTION\s*\./i.exec(mask.slice(def.index)); + if (!pub) return null; + const from = def.index + pub.index + pub[0].length; + const rest = mask.slice(from); + const endRe = /\b(PROTECTED\s+SECTION|PRIVATE\s+SECTION|ENDCLASS)\b/i; + const endM = endRe.exec(rest); + const to = endM ? from + endM.index : mask.length; + // where a new declaration goes: the start of the line that ends the section + const insertAt = source.lastIndexOf('\n', to - 1) + 1; + + const structures = new Map(); // NAME -> [{ name, type }] (type: abap text) + const tableTypesByName = new Map(); // NAME -> row type name + const attributes = []; + const declared = []; + const skipped = []; + let open = null; + for (const st of statementsOf(mask, from, to)) { + // the statement without its comments (blank on the mask), literals kept + let text = ''; + for (let i = st.start; i < st.end; i++) text += mask[i] === ' ' ? ' ' : source[i]; + text = text.replace(/\s+/g, ' ').trim(); + const types = elementsOf(text, 'TYPES'); + if (types) { + for (const el of types) { + const begin = /^BEGIN OF (\w+)$/i.exec(el); + const end = /^END OF (\w+)$/i.exec(el); + if (begin) { open = { name: begin[1].toUpperCase(), fields: [] }; continue; } + if (end) { if (open) structures.set(open.name, open.fields); open = null; continue; } + const comp = /^(\w+) TYPE (.+)$/i.exec(el); + if (!comp) continue; + if (open) open.fields.push({ name: comp[1], type: comp[2] }); + else { + const tab = /^(?:STANDARD |SORTED |HASHED )?TABLE OF (\w+)/i.exec(comp[2]); + if (tab) tableTypesByName.set(comp[1].toUpperCase(), tab[1].toUpperCase()); + } + } + continue; + } + const data = elementsOf(text, 'DATA'); + if (!data) continue; + for (const el of data) { + const m = /^(\w+) TYPE (.+?)(?: VALUE (.+))?(?: READ-ONLY)?$/i.exec(el); + if (!m) continue; + const name = m[1].toLowerCase(); + declared.push(name); + const attr = attributeOf(name, m[2]); + if (attr) { + if (m[3] && attr.type !== 'table') attr.value = seedValue(attr.type, m[3]) ?? ''; + attributes.push(attr); + } else skipped.push({ name, type: m[2] }); + } + } + return { attributes, declared, skipped, insertAt }; + + function attributeOf(name, type) { + const scalar = scalarType(type); + if (scalar) return { name, type: scalar, value: '' }; + let row = null; + const tab = /^(?:STANDARD |SORTED |HASHED )?TABLE OF (\w+)(?:\s+WITH\b.*)?$/i.exec(type.trim()); + if (tab) row = tab[1].toUpperCase(); + else if (tableTypesByName.has(type.trim().toUpperCase())) row = tableTypesByName.get(type.trim().toUpperCase()); + if (!row) return null; + const fields = fieldsOf(row, new Set()); + if (!fields) return null; + return { name, type: 'table', fields, rows: [] }; + } + + function fieldsOf(structName, seen) { + const comps = structures.get(structName); + if (!comps || seen.has(structName)) return null; + seen.add(structName); + const fields = []; + for (const c of comps) { + const scalar = scalarType(c.type); + if (scalar) { fields.push({ name: c.name.toUpperCase(), type: scalar }); continue; } + const tab = /^(?:STANDARD |SORTED |HASHED )?TABLE OF (\w+)(?:\s+WITH\b.*)?$/i.exec(c.type.trim()); + const sub = tab ? fieldsOf(tab[1].toUpperCase(), seen) : null; + if (!sub) return null; + fields.push({ name: c.name.toUpperCase(), type: 'table', fields: sub }); + } + return fields; + } +} + +// an ABAP operand as the data panel's value of that type, or undefined +function seedValue(type, operand) { + const op = operand.trim(); + // one literal, or the pieces a long text or a line break is written in + const lit = literalText(op) ?? concatText(maskSource(op), op, 0, op.length); + switch (type) { + case 'string': return lit; + case 'int': return /^-?\d+$/.test(op) ? op : lit !== undefined && /^-?\d+$/.test(lit) ? lit : undefined; + case 'decimal': { + const t = lit !== undefined ? lit : op; + return /^-?\d+(\.\d+)?$/.test(t) ? t : undefined; + } + case 'bool': + if (/^abap_true$/i.test(op) || lit === 'X') return true; + if (/^(abap_false|space)$/i.test(op) || lit === '' || lit === ' ') return false; + return undefined; + case 'date': return lit !== undefined && /^\d{8}$/.test(lit) ? `${lit.slice(0, 4)}-${lit.slice(4, 6)}-${lit.slice(6)}` : undefined; + case 'time': return lit !== undefined && /^\d{6}$/.test(lit) ? `${lit.slice(0, 2)}:${lit.slice(2, 4)}:${lit.slice(4)}` : undefined; + default: return undefined; + } +} + +// The seeds of the attributes: `name = operand.` for a scalar, `name = +// VALUE #( ( f = v ... ) ... ).` for a table - read from model_init when +// the class has one, else from the whole implementation +export function readSeeds(source, attributes) { + const mask = maskSource(source); + const impl = /\bCLASS\s+\S+\s+IMPLEMENTATION\s*\./i.exec(mask); + let from = impl ? impl.index : 0; + let to = mask.length; + const init = /\bMETHOD\s+model_init\s*\./i.exec(mask.slice(from)); + if (init) { + from += init.index + init[0].length; + const end = /\bENDMETHOD\s*\./i.exec(mask.slice(from)); + if (end) to = from + end.index; + } + const byName = new Map(attributes.map((a) => [a.name, a])); + const notes = []; + // the 7.02 form of the rows: fields set on a work area, the work area + // APPENDed to the table (or to a table field of an outer work area), + // CLEARed for the next row - kept as operands until the APPEND to the + // attribute says which fields, and so which types, they are + const work = new Map(); + const workArea = (name) => { if (!work.has(name)) work.set(name, {}); return work.get(name); }; + for (const st of statementsOf(mask, from, to)) { + const text = mask.slice(st.start, st.end); + const field = /^\s*(\w+)-(\w+)\s*=(?!=)/.exec(text); + if (field && !byName.has(field[1].toLowerCase())) { + workArea(field[1].toLowerCase())[field[2].toUpperCase()] = source.slice(st.start + field[0].length, st.end - 1); + continue; + } + const append = /^\s*APPEND\s+(\w+)\s+TO\s+(?:me->)?(\w+)(?:-(\w+))?\s*\.$/i.exec(text); + if (append) { + const row = workArea(append[1].toLowerCase()); + const target = append[2].toLowerCase(); + const attr = byName.get(target); + if (attr && !append[3] && attr.type === 'table') { + if (!attr.appended) { attr.rows = []; attr.appended = true; } + attr.rows.push(workRow(row, attr.fields, attr.name, notes)); + } else if (!attr && append[3]) { + const outer = workArea(target); + const key = append[3].toUpperCase(); + if (!Array.isArray(outer[key])) outer[key] = []; + outer[key].push(row); + } + work.set(append[1].toLowerCase(), {}); + continue; + } + const clear = /^\s*CLEAR\s+(\w+)\s*\.$/i.exec(text); + if (clear && !byName.has(clear[1].toLowerCase())) { work.set(clear[1].toLowerCase(), {}); continue; } + const m = /^\s*(?:me->)?(\w+)\s*=(?!=)/.exec(text); + if (!m) continue; + const attr = byName.get(m[1].toLowerCase()); + if (!attr || attr.seeded) continue; + const valueAt = st.start + m[0].length; + const valueEnd = st.end - 1; + if (attr.type !== 'table') { + const v = seedValue(attr.type, source.slice(valueAt, valueEnd)); + if (v === undefined) notes.push(`${attr.name}: its value is not a literal, the sample value stays empty`); + else attr.value = v; + } else { + const rows = valueRows(mask, source, valueAt, valueEnd, attr.fields, attr.name, notes); + if (rows) attr.rows = rows; + } + attr.seeded = true; + } + for (const a of attributes) { delete a.seeded; delete a.appended; } + return notes; +} + +// a work area of the 7.02 form (field -> operand, or -> the work areas +// APPENDed to that table field) as a row of the data panel +function workRow(area, fields, where, notes) { + const row = {}; + for (const [name, value] of Object.entries(area)) { + const field = (fields || []).find((f) => f.name === name); + if (!field) { notes.push(`${where}-${name}: no such field`); continue; } + if (Array.isArray(value)) { + if (field.type === 'table') row[name] = value.map((sub) => workRow(sub, field.fields, `${where}-${name}`, notes)); + else notes.push(`${where}-${name}: not a table field, its value is left out`); + continue; + } + if (field.type === 'table') { notes.push(`${where}-${name}: a table field set to a value, left out`); continue; } + const v = seedValue(field.type, value); + if (v === undefined) notes.push(`${where}-${name}: not a literal, left empty`); + else row[name] = v; + } + return row; +} + +// VALUE #( ( a = 1 b = `x` ) ( ... ) ) -> rows; a field that is itself a +// VALUE #( ) is a nested table +function valueRows(mask, source, from, to, fields, where, notes) { + const head = /^\s*VALUE\s+(?:#|\w+)\s*\(/i.exec(mask.slice(from, to)); + if (!head) { notes.push(`${where}: its rows are not a VALUE #( ) of literals, the sample rows stay empty`); return null; } + const open = from + head[0].length - 1; + const close = closingParen(mask, open, to + 1); + if (close < 0) return null; + const rows = []; + let at = open + 1; + while (at < close) { + if (mask[at] === '(') { + const rc = closingParen(mask, at, close); + if (rc < 0) break; + rows.push(rowOf(at + 1, rc)); + at = rc + 1; + } else if (/\s/.test(mask[at])) at += 1; + else { + // `LET`, `BASE`, a `FOR` - not a row + notes.push(`${where}: the VALUE #( ) has more than rows of literals, that part is left out`); + break; + } + } + return rows; + + function rowOf(a, b) { + const row = {}; + let p = a; + while (p < b) { + const m = /^\s*(\w+)\s*=(?!=)\s*/.exec(mask.slice(p, b)); + if (!m) break; + const fieldName = m[1].toUpperCase(); + p += m[0].length; + const field = fields.find((f) => f.name === fieldName); + let end; + if (/^VALUE\s+(?:#|\w+)\s*\(/i.test(mask.slice(p, b))) { + const o = mask.indexOf('(', p); + end = closingParen(mask, o, b) + 1; + if (field && field.type === 'table') row[fieldName] = valueRows(mask, source, p, end, field.fields || [], `${where}-${fieldName}`, notes) || []; + else notes.push(`${where}-${fieldName}: not a table field, its value is left out`); + } else { + // the operand runs up to the next `name =` at this level or the end + const next = /\s\w+\s*=(?!=)/.exec(mask.slice(p, b)); + end = next ? p + next.index : b; + if (field && field.type !== 'table') { + const v = seedValue(field.type, source.slice(p, end)); + if (v === undefined) notes.push(`${where}-${fieldName}: not a literal, left empty`); + else row[fieldName] = v; + } else if (!field) notes.push(`${where}-${fieldName}: no such field`); + } + p = end; + } + return row; + } +} + +// ------------------------------------------------------------- the import + +// The design an app class describes, and what the import could not take +// along. `base` is the design the result starts from (its className is +// replaced, its description emptied, its attributes are those of the class). +export function fromClass(source, base) { + const src = String(source).replace(/^/, '').replace(/\r\n?/g, '\n'); + const chain = readViewChain(src); + if (!chain.ok) throw new Error(chain.why); + if (chain.root.name !== 'View' || chain.root.ns !== 'mvc') throw new Error(`The chain builds ${chain.root.ns ? `${chain.root.ns}:` : ''}${chain.root.name}, not an mvc:View - the editor edits views (a popup or fragment is not one).`); + const pub = readPublicSection(src); + if (!pub) throw new Error('No class definition with a PUBLIC SECTION in this source.'); + const nameM = /\bCLASS\s+(\S+)\s+DEFINITION\b/i.exec(maskSource(src)); + const design = base; + design.className = nameM[1].toUpperCase(); + // the description is the abapGit sidecar's, which a .clas.abap does not carry + design.description = ''; + design.attributes = pub.attributes; + // a class without inline declarations that declares its builder is + // the 7.02 form (src/abap.js) - and stays 7.02 when it is generated anew + const masked = maskSource(src); + design.release = /\bDATA\s+view\s+TYPE\s+REF\s+TO\s+z2ui5_cl_ui5_view_builder\b/i.test(masked) && !/\bDATA\s*\(/i.test(masked) ? '702' : ''; + const seedNotes = readSeeds(src, design.attributes); + const dropped = []; + const raws = []; + const xml = chainToXml(chain.root, raws, dropped); + const rawById = {}; + fromXml(xml, design, { + events: true, + onNode: (el, node) => { if (el.attrs[RAW_MARK] !== undefined) rawById[node.id] = raws[Number(el.attrs[RAW_MARK])].attrs; }, + }); + const kept = raws.flatMap((r) => r.attrs.map((a) => ({ control: r.control, attr: a.name, abap: a.abap }))); + const report = { ...importReport(), kept, dropped, seeds: seedNotes, skipped: pub.skipped }; + design.abap = { + source: src, + chain: { start: chain.start, end: chain.end, indent: chain.indent, variable: chain.variable }, + declared: pub.declared, + insertAt: pub.insertAt, + raw: rawById, + }; + lastClassReport = report; + return design; +} + +let lastClassReport = null; + +// What the last fromClass( ) left out: the view's report (fromXml) plus +// kept: [{ control, attr, abap }] attributes the editor cannot edit, kept as raw ABAP and written back +// dropped: [{ control, attr, abap }] attributes of the View the generator writes on its own - not written back +// skipped: [{ name, type }] public attributes of a type the data panel has not +// seeds: [text] sample values that were not literals +export function classReport() { + return lastClassReport; +} + +// ------------------------------------------------------------- the export + +// where a new attribute's declaration goes and what it says +function declarations(attrs) { + const L = []; + for (const t of attrs.filter((a) => a.type === 'table')) { + for (const ty of tableTypes(t)) { + L.push(' TYPES:'); + L.push(` BEGIN OF ${ty.name},`); + const w = Math.max(...ty.fields.map((f) => f.name.length)); + for (const f of ty.fields) L.push(` ${f.name.toLowerCase().padEnd(w)} TYPE ${f.type === 'table' ? `STANDARD TABLE OF ${typeName(t, [...ty.path, f.name])} WITH EMPTY KEY` : abapType(f.type)},`); + L.push(` END OF ${ty.name}.`); + } + L.push(` DATA ${t.name} TYPE STANDARD TABLE OF ${typeName(t)} WITH EMPTY KEY.`); + } + for (const a of attrs.filter((a) => a.type !== 'table')) L.push(` DATA ${a.name} TYPE ${abapType(a.type)}.`); + return L; +} + +// the WHEN branch for an event the class does not handle yet, placed in the +// CASE client->get_event( ) before WHEN OTHERS, else before ENDCASE +function placeEvents(source, events) { + const mask = maskSource(source); + const caseM = /\bCASE\s+client->get_event\(\s*\)\s*\./i.exec(mask); + if (!caseM) return { source, unplaced: events }; + const endM = /\bENDCASE\s*\./i.exec(mask.slice(caseM.index)); + if (!endM) return { source, unplaced: events }; + const region = source.slice(caseM.index, caseM.index + endM.index); + const whenM = /\n([ \t]*)WHEN\s+OTHERS\b/i.exec(region); + const whens = [...region.matchAll(/\n([ \t]*)WHEN\b/gi)]; + const indent = whenM ? whenM[1] : whens.length ? whens[0][1] : ' '.repeat((/\n(\s*)$/.exec(source.slice(0, caseM.index)) || ['', ''])[1].length + 2); + // the start of the line of WHEN OTHERS / ENDCASE, so its indentation stays its own + const at = whenM ? caseM.index + whenM.index + 1 : source.lastIndexOf('\n', caseM.index + endM.index - 1) + 1; + const lines = events.flatMap((ev) => [`${indent}WHEN ${abapString(ev)}.`, `${indent} client->message_toast_display( ${abapString(`${ev} - implement me in on_event`)} ).`]); + return { source: source.slice(0, at) + lines.join('\n') + '\n' + source.slice(at), unplaced: [] }; +} + +// The class of a design: generated when the design was drawn here, the +// imported class with the regenerated chain when it came from one. With +// nodeIds per line (the code view's link to the canvas) and `notes` on what +// the patch could not place +export function classSource(design) { + if (!design.abap || !design.abap.source) return { ...generateClassMap(design), notes: [] }; + const { source: orig, chain, declared, insertAt } = design.abap; + const notes = []; + // 1. the chain, in the house layout, over the range the old one occupied - + // the raw attributes generated as placeholder literals, then swapped for + // their ABAP (the `v =` column depends on the names only) + const { design: withRaw, swaps } = rawPlaceholders(design); + const lines = viewChain(withRaw, chain.indent); + for (let i = 0; i < lines.length; i++) { + lines[i] = lines[i].replace(/\bv = `__le_raw_(\d+)__`/g, (m, n) => `${swaps[n].key} = ${swaps[n].abap}`); + } + if (chain.variable && chain.variable !== 'view') lines[0] = lines[0].replace(/^(\s*)DATA\(view\)/, `$1DATA(${chain.variable})`); + const chainText = lines.join('\n'); + let out = orig.slice(0, chain.start) + chainText + orig.slice(chain.end); + let chainLine = orig.slice(0, chain.start).split('\n').length - 1; + // 2. attributes the editor added, declared at the end of the PUBLIC SECTION + const known = new Set((declared || []).map((n) => n.toLowerCase())); + const added = design.attributes.filter((a) => !known.has(a.name.toLowerCase())); + if (added.length && insertAt != null && insertAt <= chain.start) { + const decl = declarations(added); + const before = out.slice(0, insertAt); + const blankBefore = /\n\n$/.test(before) || before === ''; + const text = (blankBefore ? '' : '\n') + decl.join('\n') + '\n\n'; + out = before + text + out.slice(insertAt); + chainLine += text.split('\n').length - 1; + } else if (added.length) { + notes.push(`${added.length} attribute(s) could not be declared - add them to the PUBLIC SECTION by hand: ${added.map((a) => a.name).join(', ')}`); + } + // 3. events without a WHEN branch + const handled = new Set(); + const mask = maskSource(orig); + for (const m of orig.matchAll(/\bWHEN\s+([`'])([A-Za-z0-9_]+)\1/gi)) if (mask[m.index] === 'W') handled.add(m[2]); + for (const m of orig.matchAll(/check_on_event\(\s*([`'])([A-Za-z0-9_]+)\1\s*\)/gi)) handled.add(m[2]); + const newEvents = collectEvents(design).filter((e) => !handled.has(e)); + if (newEvents.length) { + const placed = placeEvents(out, newEvents); + if (placed.unplaced.length) notes.push(`${placed.unplaced.length} event(s) have no on_event branch - the class has no CASE client->get_event( ) to put one in: ${placed.unplaced.join(', ')}`); + else { + const chainOffset = out.split('\n').slice(0, chainLine).join('\n').length; + const insertedBefore = placed.source.length !== out.length && placed.source.slice(0, chainOffset) !== out.slice(0, chainOffset); + if (insertedBefore) chainLine += placed.source.split('\n').length - out.split('\n').length; + } + out = placed.source; + } + if (!out.endsWith('\n')) out += '\n'; + const total = out.split('\n').length; + const nodeIds = new Array(total).fill(null); + lines.nodeIds.forEach((id, i) => { nodeIds[chainLine + i] = id; }); + return { source: out, nodeIds, notes }; +} + +// The design with each kept raw attribute as an expression the generator +// writes as a literal `__le_raw_N__` - on the node it was read with, when +// that node is still there and the editor does not set the attribute itself +function rawPlaceholders(design) { + const raw = (design.abap && design.abap.raw) || {}; + const swaps = []; + if (!Object.keys(raw).length) return { design, swaps }; + const copy = clone(design); + walk(copy.root, (n) => { + for (const a of raw[n.id] || []) { + if (isSetValue(n.props[a.name]) || (n.events && n.events[a.name])) continue; + n.props[a.name] = { m: 'x', v: `__le_raw_${swaps.length}__` }; + swaps.push(a); + } + return true; + }); + return { design: copy, swaps }; +} + +export function generateClassSource(design) { + return classSource(design).source; +} + +// the abapGit files, with the imported class where the design came from one +export function classFiles(design) { + const files = abapGitFiles(design); + files[`src/${fileName(design.className, 'clas.abap')}`] = generateClassSource(design); + return files; +} + +// ------------------------------------------------------------- where a class comes from + +const RAW_HOSTS = ['raw.githubusercontent.com', 'gist.githubusercontent.com']; + +// The URL a class may be fetched from: this site, or GitHub's raw hosts; a +// github.com page URL (blob/raw) is translated to the raw host. Throws for +// anything else - the editor fetches on behalf of whoever opened the link +export function sourceUrl(raw, origin) { + let url; + try { url = new URL(raw, origin); } catch { throw new Error(`${raw} is not a URL`); } + if (url.username || url.password) throw new Error('A link to a class carries no user name or password'); + if (url.hostname === 'github.com') { + const m = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname); + if (m) url = new URL(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`); + } + if (origin && url.origin === new URL(origin).origin) return url.href; + if (url.protocol === 'https:' && RAW_HOSTS.includes(url.hostname)) return url.href; + throw new Error(`Classes are opened from ${RAW_HOSTS.join(' or ')} or from this site - not from ${url.hostname || url.protocol}`); +} + +// ?class=zcl_my_app&repo=owner/repo[&ref=main][&path=src] -> the raw URL +export function repoClassUrl(className, repo, ref = 'main', path = 'src') { + const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(String(repo || '').trim()); + if (!m) throw new Error(`${repo} is not owner/repo`); + if (!/^[A-Za-z0-9_/]+$/.test(className || '')) throw new Error(`${className} is not a class name`); + const dir = String(path || '').replace(/^\/+|\/+$/g, ''); + return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${encodeURIComponent(ref)}/${dir ? `${dir}/` : ''}${fileName(className, 'clas.abap')}`; +} diff --git a/src/handoff.js b/src/handoff.js new file mode 100644 index 0000000..050680a --- /dev/null +++ b/src/handoff.js @@ -0,0 +1,47 @@ +// Handing the class on: a link that opens it in the abap2UI5 playground, +// and one that opens it in VS Code with the abap2UI5 extension. +// +// The playground's share link is its own format (src/shell/share.mjs in +// abap2UI5/playground): the fragment is one character of version, "2", then +// base64url of the deflated JSON `[{ name, source }]` - the files it opens, +// the first one the app it starts. Written here byte for byte the same, so +// a link from the editor is a link the playground reads today. +// +// The VS Code link is the extension's URI handler: +// vscode://abap2ui5.abap2ui5/open?code=&name= +// (or ?src= for a class that is on GitHub). The extension opens +// the class as an untitled .clas.abap document and offers to run it. + +export const PLAYGROUND_URL = 'https://abap2ui5.github.io/playground/'; +export const VSCODE_URI = 'vscode://abap2ui5.abap2ui5/open'; + +const CHUNK = 0x8000; + +export function toBase64Url(bytes) { + const parts = []; + for (let i = 0; i < bytes.length; i += CHUNK) parts.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK))); + return btoa(parts.join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export async function deflate(text) { + const bytes = new TextEncoder().encode(text); + return new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer()); +} + +// the playground's fragment for one or more files: version 2 +export async function playgroundFragment(files) { + const payload = JSON.stringify(files.map(({ name, source }) => ({ name, source }))); + return '2' + toBase64Url(await deflate(payload)); +} + +export async function playgroundLink(fileName, source, base = PLAYGROUND_URL) { + const url = new URL(base); + url.search = ''; + url.hash = await playgroundFragment([{ name: fileName, source }]); + return url.href; +} + +export async function vscodeLink(fileName, source, base = VSCODE_URI) { + const code = toBase64Url(await deflate(source)); + return `${base}?code=${code}&name=${encodeURIComponent(fileName)}`; +} diff --git a/src/model.js b/src/model.js index b35c52e..038857e 100644 --- a/src/model.js +++ b/src/model.js @@ -73,6 +73,8 @@ export function normalizeDesign(d) { } d.className = String(d.className || 'ZCL_MY_APP'); d.description = String(d.description || ''); + // the ABAP release of the class: 7.02, or nothing for 7.40 and later + if (d.release === '702') d.release = '702'; else delete d.release; d.attributes = Array.isArray(d.attributes) ? d.attributes : []; d.root.id = 'root'; d.root.type = 'mvc:View'; diff --git a/src/preview/init.js b/src/preview/init.js index abbbbf0..f15a0a0 100644 --- a/src/preview/init.js +++ b/src/preview/init.js @@ -327,5 +327,11 @@ sap.ui.define([ post({ type: 'error', message: text, runtime: true }); }); + // Horizon exists from 1.102 on: a UI5 of your own (?ui5=) that + // turns out older gets Quartz, as a version from the CDN does + var minor = /^1\.(\d+)\./.exec(sap.ui.version); + var core = sap.ui.getCore(); + if (minor && Number(minor[1]) < 102 && core.getConfiguration().getTheme() === 'sap_horizon') core.applyTheme('sap_fiori_3'); + post({ type: 'ready', version: sap.ui.version }); }); diff --git a/src/starters.js b/src/starters.js index 8ea57f7..0c4ec16 100644 --- a/src/starters.js +++ b/src/starters.js @@ -5,6 +5,50 @@ function page(d) { return d.root.aggs.content[0]; } +// a node from a spec: a string prop is a value, an object one a binding +// ({ m: 'b' | 'f', v }); aggs hold nodes, and replace the control's own +// initial children +function el(type, { props = {}, events = {}, aggs } = {}) { + const n = createNode(type, aggs ? { aggs: {} } : undefined); + n.props = {}; // no sample texts of the palette's + for (const [k, v] of Object.entries(props)) n.props[k] = typeof v === 'string' ? { m: 's', v } : v; + Object.assign(n.events, events); + for (const [a, children] of Object.entries(aggs || {})) n.aggs[a] = children; + return n; +} +const bind = (v) => ({ m: 'b', v }); +const field = (v) => ({ m: 'f', v }); + +// a sap.m.Table bound to a table attribute: [header, cell] per column +function table(attr, columns, props, row) { + const t = el('Table', { + props: { ...props, items: bind(attr) }, + aggs: { + columns: columns.map(([header]) => el('Column', { aggs: { header: [el('Text', { props: { text: header } })] } })), + items: [el('ColumnListItem', { ...row, aggs: { cells: columns.map(([, cell]) => cell) } })], + }, + }); + if (!(props && props.headerText)) delete t.props.headerText; + return t; +} + +// "Orders (3)": the title over a table with the number of its rows - an +// attribute of its own, as in the UI5 worklist template, for the class to +// set from lines( ) of the table +const countTitle = (attr) => el('Title', { props: { text: bind(attr) } }); +const countAttr = (name, text, table) => ({ name, type: 'string', value: `${text} (${table.rows.length})` }); + +const ORDERS = { + name: 't_orders', type: 'table', + fields: [{ name: 'ID', type: 'string' }, { name: 'CUSTOMER', type: 'string' }, { name: 'CITY', type: 'string' }, { name: 'AMOUNT', type: 'decimal' }, { name: 'STATUS', type: 'string' }, { name: 'STATE', type: 'string' }], + rows: [ + { ID: '4711', CUSTOMER: 'Jane Doe', CITY: 'Walldorf', AMOUNT: '1250.00', STATUS: 'Open', STATE: 'Warning' }, + { ID: '4712', CUSTOMER: 'John Smith', CITY: 'Berlin', AMOUNT: '89.90', STATUS: 'Shipped', STATE: 'Success' }, + { ID: '4713', CUSTOMER: 'Erika Mustermann', CITY: 'Hamburg', AMOUNT: '420.50', STATUS: 'Blocked', STATE: 'Error' }, + ], +}; +const orders = () => JSON.parse(JSON.stringify(ORDERS)); + export const STARTERS = [ { id: 'blank', @@ -89,6 +133,174 @@ export const STARTERS = [ return d; }, }, + { + id: 'list-report', + title: 'List report', + text: 'Filter fields and a search over a table of orders, with the number of rows in its title.', + make() { + const d = createDesign(); + d.className = 'ZCL_MY_LIST_REPORT'; + d.description = 'Orders'; + d.attributes = [ + { name: 'search', type: 'string', value: '' }, + { name: 'customer', type: 'string', value: '' }, + { name: 'city', type: 'string', value: '' }, + orders(), + countAttr('orders_title', 'Orders', ORDERS), + ]; + const filter = (label, attr) => el('VBox', { aggs: { items: [el('Label', { props: { text: label } }), el('Input', { props: { value: bind(attr) } })] } }); + d.root.aggs.content = [el('f:DynamicPage', { + aggs: { + title: [el('f:DynamicPageTitle', { aggs: { heading: [el('Title', { props: { text: 'Orders' } })] } })], + header: [el('f:DynamicPageHeader', { + aggs: { + content: [el('l:Grid', { + props: { defaultSpan: 'XL3 L3 M6 S12' }, + aggs: { + content: [ + filter('Customer', 'customer'), + filter('City', 'city'), + el('VBox', { aggs: { items: [el('Label', { props: { text: 'Search' } }), el('SearchField', { props: { value: bind('search') }, events: { search: 'SEARCH' } })] } }), + el('Button', { props: { text: 'Go', type: 'Emphasized' }, events: { press: 'GO' } }), + ], + }, + })], + }, + })], + content: [table('t_orders', [ + ['Order', el('ObjectIdentifier', { props: { title: field('ID') } })], + ['Customer', el('Text', { props: { text: field('CUSTOMER') } })], + ['City', el('Text', { props: { text: field('CITY') } })], + ['Amount', el('ObjectNumber', { props: { number: field('AMOUNT'), unit: 'EUR' } })], + ], { growing: 'true' })], + }, + })]; + const t = d.root.aggs.content[0].aggs.content[0]; + t.aggs.headerToolbar = [el('OverflowToolbar', { aggs: { content: [countTitle('orders_title')] } })]; + return d; + }, + }, + { + id: 'object-page', + title: 'Object page', + text: 'One object: a header with its key facts, and sections with a form and a table of its items.', + make() { + const d = createDesign(); + d.className = 'ZCL_MY_OBJECT_PAGE'; + d.description = 'Order'; + d.attributes = [ + { name: 'id', type: 'string', value: '4711' }, + { name: 'customer', type: 'string', value: 'Jane Doe' }, + { name: 'city', type: 'string', value: 'Walldorf' }, + { name: 'ordered_on', type: 'date', value: '2026-10-01' }, + { name: 'amount', type: 'decimal', value: '1250.00' }, + { name: 'status', type: 'string', value: 'Open' }, + { + name: 't_items', type: 'table', + fields: [{ name: 'POS', type: 'int' }, { name: 'PRODUCT', type: 'string' }, { name: 'QUANTITY', type: 'int' }, { name: 'PRICE', type: 'decimal' }], + rows: [ + { POS: '10', PRODUCT: 'Notebook', QUANTITY: '1', PRICE: '999.00' }, + { POS: '20', PRODUCT: 'Monitor', QUANTITY: '1', PRICE: '249.00' }, + { POS: '30', PRODUCT: 'Cable', QUANTITY: '2', PRICE: '1.00' }, + ], + }, + ]; + const pair = (label, value) => [el('Label', { props: { text: label } }), el('Text', { props: { text: value } })]; + const section = (title, blocks) => el('uxap:ObjectPageSection', { + props: { title }, + aggs: { subSections: [el('uxap:ObjectPageSubSection', { aggs: { blocks } })] }, + }); + d.root.aggs.content = [el('uxap:ObjectPageLayout', { + props: { showAnchorBar: 'true' }, + aggs: { + headerTitle: [el('uxap:ObjectPageDynamicHeaderTitle', { + aggs: { + heading: [el('Title', { props: { text: bind('customer') } })], + actions: [el('Button', { props: { text: 'Edit', type: 'Emphasized' }, events: { press: 'EDIT' } })], + }, + })], + headerContent: [ + el('ObjectAttribute', { props: { title: 'Order', text: bind('id') } }), + el('ObjectNumber', { props: { number: bind('amount'), unit: 'EUR', emphasized: 'true' } }), + el('ObjectStatus', { props: { title: 'Status', text: bind('status'), state: 'Warning' } }), + ], + sections: [ + section('General', [el('form:SimpleForm', { + props: { editable: 'false', layout: 'ResponsiveGridLayout' }, + aggs: { content: [...pair('Customer', bind('customer')), ...pair('City', bind('city')), ...pair('Ordered on', bind('ordered_on'))] }, + })]), + section('Items', [table('t_items', [ + ['Position', el('Text', { props: { text: field('POS') } })], + ['Product', el('Text', { props: { text: field('PRODUCT') } })], + ['Quantity', el('Text', { props: { text: field('QUANTITY') } })], + ['Price', el('ObjectNumber', { props: { number: field('PRICE'), unit: 'EUR' } })], + ])]), + ], + }, + })]; + return d; + }, + }, + { + id: 'worklist', + title: 'Worklist', + text: 'A table of work items with a status column; a row press opens one.', + make() { + const d = createDesign(); + d.className = 'ZCL_MY_WORKLIST'; + d.description = 'Orders to process'; + d.attributes = [{ name: 'search', type: 'string', value: '' }, orders(), countAttr('orders_title', 'Orders', ORDERS)]; + const p = page(d); + p.props.title = { m: 's', v: 'Orders to process' }; + p.aggs.content = [table('t_orders', [ + ['Order', el('ObjectIdentifier', { props: { title: field('ID'), text: field('CUSTOMER') } })], + ['Amount', el('ObjectNumber', { props: { number: field('AMOUNT'), unit: 'EUR' } })], + ['Status', el('ObjectStatus', { props: { text: field('STATUS'), state: field('STATE') } })], + ], {}, { props: { type: 'Navigation' }, events: { press: 'ROW_PRESS' } })]; + p.aggs.content[0].aggs.headerToolbar = [el('OverflowToolbar', { + aggs: { content: [countTitle('orders_title'), el('ToolbarSpacer'), el('SearchField', { props: { value: bind('search'), width: '15rem' }, events: { search: 'SEARCH' } })] }, + })]; + return d; + }, + }, + { + id: 'master-detail', + title: 'Master-detail', + text: 'A list of orders next to the details of the one selected, in two columns.', + make() { + const d = createDesign(); + d.className = 'ZCL_MY_MASTER_DETAIL'; + d.description = 'Orders'; + d.attributes = [ + orders(), + { name: 'customer', type: 'string', value: 'Jane Doe' }, + { name: 'id', type: 'string', value: '4711' }, + { name: 'city', type: 'string', value: 'Walldorf' }, + { name: 'amount', type: 'decimal', value: '1250.00' }, + { name: 'status', type: 'string', value: 'Open' }, + ]; + const p = page(d); + p.props.title = { m: 's', v: 'Orders' }; + const list = el('List', { + props: { headerText: 'Orders', mode: 'SingleSelectMaster', items: bind('t_orders') }, + events: { selectionChange: 'SELECT' }, + aggs: { items: [el('StandardListItem', { props: { title: field('CUSTOMER'), description: field('ID'), info: field('STATUS'), infoState: field('STATE') } })] }, + }); + const detail = el('VBox', { + aggs: { + items: [el('ObjectHeader', { + props: { title: bind('customer'), number: bind('amount'), numberUnit: 'EUR' }, + aggs: { + attributes: [el('ObjectAttribute', { props: { title: 'Order', text: bind('id') } }), el('ObjectAttribute', { props: { title: 'City', text: bind('city') } })], + statuses: [el('ObjectStatus', { props: { text: bind('status'), state: 'Warning' } })], + }, + })], + }, + }); + p.aggs.content = [el('l:Grid', { props: { defaultSpan: 'XL6 L6 M6 S12' }, aggs: { content: [list, detail] } })]; + return d; + }, + }, { id: 'tabs', title: 'Tabs', diff --git a/src/ui/code.js b/src/ui/code.js index 83b5376..4c6df16 100644 --- a/src/ui/code.js +++ b/src/ui/code.js @@ -2,7 +2,8 @@ // to the design node that writes it; and the layout / code switches. import { state, savePrefs } from './store.js'; import { $, toast } from './dom.js'; -import { generateClassMap, fileName } from '../abap.js'; +import { fileName } from '../abap.js'; +import { classSource } from '../clas.js'; import { toXml } from '../xml.js'; @@ -31,10 +32,12 @@ export function renderCode() { let lines; let nodeIds; if (state.code === 'abap') { - const map = generateClassMap(d); + const map = classSource(d); lines = map.source.replace(/\n$/, '').split('\n'); nodeIds = map.nodeIds; - $('#codeInfo').textContent = `${fileName(d.className, 'clas.abap')} · ${lines.length} lines`; + const origin = d.abap ? ' · imported class, the view regenerated' : ''; + const notes = map.notes.length ? ` · ${map.notes.join(' · ')}` : ''; + $('#codeInfo').textContent = `${fileName(d.className, 'clas.abap')} · ${lines.length} lines${origin}${notes}`; } else { lines = toXml(d).split('\n'); nodeIds = []; diff --git a/src/ui/files.js b/src/ui/files.js index 4248072..b81db92 100644 --- a/src/ui/files.js +++ b/src/ui/files.js @@ -3,7 +3,9 @@ import { STARTERS } from '../starters.js'; import { fromXml, importReport } from '../xml.js'; import { clone, walk, normalizeDesign, validate, checkAttributeName } from '../model.js'; import { copyText } from './code.js'; -import { abapGitFiles } from '../abap.js'; +import { fileName } from '../abap.js'; +import { fromClass, classReport, classFiles, generateClassSource, sourceUrl, repoClassUrl } from '../clas.js'; +import { playgroundLink, vscodeLink, PLAYGROUND_URL } from '../handoff.js'; import { zip } from '../zip.js'; import { state, commit, replaceDesign, showPanel, recentDesigns, designToLink } from './store.js'; import { $, h, toast, download, dialog } from './dom.js'; @@ -125,6 +127,120 @@ function createImportedAttributes(unresolved) { toast('Attributes created - edit them on the Data tab'); } +// ------------------------------------------------------------- a class + +export function openClassDialog() { + const ta = h('textarea', { placeholder: 'CLASS zcl_my_app DEFINITION PUBLIC.\n PUBLIC SECTION.\n INTERFACES z2ui5_if_app.\n…', spellcheck: 'false', 'aria-label': 'ABAP class' }); + const url = h('input', { placeholder: 'https://raw.githubusercontent.com/owner/repo/main/src/zcl_my_app.clas.abap', spellcheck: 'false', 'aria-label': 'URL of the class' }); + dialog('Open an abap2UI5 app class', h('div', {}, + h('p', { class: 'hint', text: 'Paste the class, open a .clas.abap file, or give its URL on GitHub. Its view becomes the design, its public attributes the data, its events the event names - and the class keeps everything else: the export writes the view back into it.' }), + ta, + h('p', { class: 'hint', text: 'Or from GitHub (raw.githubusercontent.com, or the page of the file):' }), + url), [ + { text: 'Cancel', action: () => true }, + { text: 'Open file…', action: () => { openFile('.abap'); return true; } }, + { + text: 'Open', primary: true, + action: () => { + const text = ta.value.trim(); + if (!text && url.value.trim()) { $('#dlg').close(); fetchClass(url.value.trim()); return false; } + try { + $('#dlg').close(); // the import opens its summary in the same dialog + importClassText(text); + return false; + } catch (err) { + toast(err.message); + return false; + } + }, + }, + ]); + ta.focus(); +} + +// ?src= or ?class=&repo=: the class replaces the +// design, the address stays clean (the ?ui5 of the tests included) +export function openClassFromUrl(params) { + let url = params.get('src'); + try { + if (!url && params.get('class') && params.get('repo')) url = repoClassUrl(params.get('class'), params.get('repo'), params.get('ref') || 'main', params.get('path') || 'src'); + } catch (err) { + toast(err.message); + } + if (!url) return; + for (const k of ['src', 'class', 'repo', 'ref', 'path']) params.delete(k); + const q = params.toString(); + history.replaceState(null, '', `${location.pathname}${q ? `?${q}` : ''}${location.hash}`); + fetchClass(url); +} + +async function fetchClass(raw) { + try { + const href = sourceUrl(raw, location.href); + const res = await fetch(href, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) throw new Error(`${href} answered ${res.status}`); + const text = await res.text(); + if (!/^\s*(?:"|\*|CLASS\b)/im.test(text)) throw new Error(`${href} is not an ABAP class`); + importClassText(text); + } catch (err) { + toast(`Cannot open the class: ${err.message}`); + } +} + +function importClassText(text) { + if (!text) throw new Error('Nothing to open'); + const d = fromClass(text, clone(state.design)); + replaceDesign(d); + const r = classReport(); + const lines = []; + const seen = `${d.attributes.length} attribute(s), ${r.unknown.length ? 'controls the palette does not know kept as imported: ' + r.unknown.join(', ') : 'every control in the palette'}`; + lines.push(seen); + if (r.kept.length) lines.push(`Kept as ABAP - ${r.kept.length} attribute(s) the editor has no field for, written back into the view as they are (set one in the editor to replace it): ${r.kept.map((x) => `${x.control}.${x.attr} = ${x.abap}`).join('; ')}`); + if (r.dropped.length) lines.push(`Could not import - ${r.dropped.length} attribute(s) of the View, which the editor writes itself: ${r.dropped.map((x) => `${x.attr} = ${x.abap}`).join('; ')}`); + if (r.skipped.length) lines.push(`Public attributes of a type the Data tab has not, left to the class: ${r.skipped.map((x) => `${x.name} (${x.type})`).join(', ')}`); + if (r.seeds.length) lines.push(`Sample data not read: ${r.seeds.join('; ')}`); + if (r.handlers.length) lines.push(`${r.handlers.length} event handler(s) dropped: ${[...new Set(r.handlers)].join(', ')}`); + if (r.expressions) lines.push(`${r.expressions} binding(s) kept as UI5 expressions (ƒx)`); + const body = h('div', {}, lines.map((t) => h('p', { text: t }))); + const buttons = [{ text: 'OK', primary: !r.unresolved.length, action: () => true }]; + if (r.unresolved.length) { + body.append(h('p', {}, h('b', { text: `${r.unresolved.length} binding(s) name no public attribute: ` }), r.unresolved.map((u) => `{/${u.path}}`).join(', '))); + buttons.unshift({ text: `Create ${r.unresolved.length} attribute(s)`, primary: true, action: () => { createImportedAttributes(r.unresolved); return true; } }); + } + dialog(`Opened ${d.className}`, body, buttons); +} + +// ------------------------------------------------------------- hand-off + +function classFile() { + return { name: fileName(state.design.className, 'clas.abap'), source: generateClassSource(state.design) }; +} + +// the playground's base URL: ?playground= once, remembered +export function playgroundBase() { + return state.prefs.playground || PLAYGROUND_URL; +} + +export async function openInPlayground() { + try { + const f = classFile(); + window.open(await playgroundLink(f.name, f.source, playgroundBase()), '_blank', 'noopener'); + } catch (err) { + toast(`No playground link: ${err.message}`); + } +} + +export async function openInVscode() { + try { + const f = classFile(); + const href = await vscodeLink(f.name, f.source); + location.assign(href); + toast('Opening in VS Code - the abap2UI5 extension takes the class', { label: 'Copy link', run: () => copyText(href, 'Link') }); + } catch (err) { + toast(`No VS Code link: ${err.message}`); + } +} + export function openFile(accept) { const inp = $('#fileInput'); inp.accept = accept || '.json,.xml'; @@ -136,6 +252,8 @@ export function openFile(accept) { try { if (/\.xml$/i.test(f.name) || /^\s* i.level === 'error'); if (errors.length && !confirm(`${errors.length} problem(s) - the class may not activate:\n\n${errors.slice(0, 5).map((e) => '• ' + e.text).join('\n')}\n\nExport anyway?`)) return; - const bytes = zip(abapGitFiles(d)); + const bytes = zip(classFiles(d)); download(`${d.className.toLowerCase().replace(/\//g, '#')}.zip`, new Blob([bytes], { type: 'application/zip' })); toast('Exported - abapGit: New Offline, Import ZIP'); } diff --git a/src/ui/store.js b/src/ui/store.js index a6774a3..df2d333 100644 --- a/src/ui/store.js +++ b/src/ui/store.js @@ -45,11 +45,29 @@ export const state = { prefs: { device: '100%', theme: 'sap_horizon', ui5: '', view: 'split' }, }; +const TRUSTED_UI5_HOSTS = ['localhost', '127.0.0.1', '[::1]', 'sdk.openui5.org', 'ui5.sap.com', 'openui5.hana.ondemand.com', 'sapui5.hana.ondemand.com']; + +// a ?ui5= a link may set: a version, local, a path on this server, or a +// URL on this host, localhost or one of SAP's UI5 CDNs +export function isLinkableUi5(v) { + if (!/^https?:/i.test(v)) return true; + try { + const { hostname } = new URL(v); + return hostname === location.hostname || TRUSTED_UI5_HOSTS.includes(hostname); + } catch { return false; } +} + export function loadPrefs() { try { Object.assign(state.prefs, JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')); } catch { /* defaults */ } // ?ui5= overrides the stored version, for links and tests + // - but a UI5 on a host of its own only when the user typed it into + // the UI5 field: its scripts run on this origin, a link is anybody's const q = new URLSearchParams(location.search).get('ui5'); - if (q !== null) state.prefs.ui5 = q; + if (q !== null && isLinkableUi5(q)) state.prefs.ui5 = q; + else if (q !== null) state.refusedUi5 = q; + // ?playground= sets where "Open in Playground" goes + const pg = new URLSearchParams(location.search).get('playground'); + if (pg !== null) { state.prefs.playground = pg; savePrefs(); } state.view = state.prefs.view || 'split'; } diff --git a/src/xml.js b/src/xml.js index 69393d8..da1aa77 100644 --- a/src/xml.js +++ b/src/xml.js @@ -165,8 +165,11 @@ export function parseXml(src) { // Event handlers, ids and core:require are dropped - they name controller // methods abap2UI5 does not have. What was dropped or could not be // resolved is listed in the report (`importReport( )`), so the designer -// can say so and offer to create the attributes. -export function fromXml(src, base) { +// can say so and offer to create the attributes. With `options.events` a +// handler in the preview flavour this module writes, `.onEvent('NAME')`, +// is the event NAME instead - what a view read out of a class carries; +// `options.onNode(el, node)` sees every element with the node it became. +export function fromXml(src, base, options = {}) { const doc = parseXml(src); if (!doc) throw new Error('XML: empty document'); const uriToPrefix = {}; @@ -247,10 +250,13 @@ export function fromXml(src, base) { const c = getControl(type); if (!c && !report.unknown.includes(type)) report.unknown.push(type); const node = { id: newId(), type, props: {}, events: {}, aggs: {} }; + if (options.onNode) options.onNode(el, node); const bindable = c && c.aggs ? Object.keys(c.aggs).filter((a) => c.aggs[a].bindable) : []; for (const [k, v] of Object.entries(el.attrs)) { if (k === 'xmlns' || k.startsWith('xmlns:') || k.includes(':')) continue; if (k === 'id') { report.ids += 1; continue; } + const named = options.events && /^\.onEvent\('([A-Z0-9_]+)'\)$/.exec(v); + if (named) { node.events[k] = named[1]; continue; } if ((c && c.events && c.events.includes(k)) || /^\s*\.[A-Za-z_$]/.test(v)) { report.handlers.push(`${local}.${k}`); continue; } node.props[k] = isBinding(v) ? bindingValue(v, k, bindable.includes(k), rowTable) : { m: 's', v: unescapeBindingLiteral(v) }; } diff --git a/test/abaplint-deps/cl_abap_char_utilities.clas.abap b/test/abaplint-deps/cl_abap_char_utilities.clas.abap new file mode 100644 index 0000000..3a6998c --- /dev/null +++ b/test/abaplint-deps/cl_abap_char_utilities.clas.abap @@ -0,0 +1,12 @@ +" The three constants a generated 7.02 class may use for a newline, a +" carriage return or a tab in a text - declared here so the abaplint 7.02 +" check (abaplint-702.jsonc) knows them without the whole standard library. +" Not part of the generated class; the runtime check runs on open-abap-core. +CLASS cl_abap_char_utilities DEFINITION PUBLIC FINAL. + PUBLIC SECTION. + CONSTANTS newline TYPE c LENGTH 1 VALUE '_'. + CONSTANTS cr_lf TYPE c LENGTH 2 VALUE '__'. + CONSTANTS horizontal_tab TYPE c LENGTH 1 VALUE '_'. +ENDCLASS. +CLASS cl_abap_char_utilities IMPLEMENTATION. +ENDCLASS. diff --git a/test/abaplint-deps/if_serializable_object.intf.abap b/test/abaplint-deps/if_serializable_object.intf.abap new file mode 100644 index 0000000..2bc68b6 --- /dev/null +++ b/test/abaplint-deps/if_serializable_object.intf.abap @@ -0,0 +1,5 @@ +" z2ui5_if_app includes this standard interface; declared here so the +" abaplint 7.02 check (abaplint-702.jsonc) resolves the app interface +" without the whole standard library. +INTERFACE if_serializable_object PUBLIC. +ENDINTERFACE. diff --git a/test/builder.mjs b/test/builder.mjs index 9958b66..44f72a3 100644 --- a/test/builder.mjs +++ b/test/builder.mjs @@ -3,7 +3,9 @@ // element the chain points at (the last child, or the node itself while it // has none), end( ) ascends, and the view renders from the root. Enough of // ABAP to evaluate what the generator writes into a( ): backtick literals -// joined with &&, string templates, client->_bind( ) and client->_event( ). +// joined with &&, string templates, client->_bind( ) and client->_event( ) - +// and what the 7.02 class writes instead of a template: the constants of +// cl_abap_char_utilities, and _bind( val = … path = abap_true ) in a path. import { escapeBindingLiteral, escapeXml } from '../src/xml.js'; function evalExpr(src) { @@ -50,6 +52,10 @@ function evalExpr(src) { } let m = /^client->_bind\( (\w+) \)/.exec(s.slice(i)); if (m) { parts.push(`{/${m[1].toUpperCase()}}`); i += m[0].length; continue; } + m = /^client->_bind\( val = (\w+) path = abap_true \)/.exec(s.slice(i)); + if (m) { parts.push(`/${m[1].toUpperCase()}`); i += m[0].length; continue; } + m = /^cl_abap_char_utilities=>(newline|cr_lf\(1\)|cr_lf|horizontal_tab)/.exec(s.slice(i)); + if (m) { parts.push({ newline: '\n', 'cr_lf(1)': '\r', cr_lf: '\r\n', horizontal_tab: '\t' }[m[1]]); i += m[0].length; continue; } m = /^client->_event\( `([A-Z0-9_]+)` \)/.exec(s.slice(i)); if (m) { parts.push(`.onEvent('${m[1]}')`); i += m[0].length; continue; } throw new Error(`cannot evaluate: ${s.slice(i)}`); diff --git a/test/clas.test.mjs b/test/clas.test.mjs new file mode 100644 index 0000000..4f6944a --- /dev/null +++ b/test/clas.test.mjs @@ -0,0 +1,313 @@ +// An ABAP class -> design (src/clas.js) and back: every starter's and the +// kitchen sink's class comes back as the design it was generated from - +// byte-identical whether the export patches the imported class or +// generates it anew -, a real sample from abap2UI5/samples imports with +// its view, and what the editor cannot take along is reported, never +// dropped. The abap2UI5 linter's own reconstruction of the chain is the +// independent witness for the view. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { prepareAbap } from '@abap2ui5/linter/reconstruct'; +import { allDesigns, allDesigns702 } from './fixtures.mjs'; +import { generateClass } from '../src/abap.js'; +import { toXml } from '../src/xml.js'; +import { createDesign, createNode, insertNode, validate, walk, aggNames, clone } from '../src/model.js'; +import { fromClass, classReport, classSource, generateClassSource, classFiles, readViewChain, readPublicSection, readSeeds, sourceUrl, repoClassUrl } from '../src/clas.js'; + +const SAMPLE = new URL('./fixtures/z2ui5_cl_smp_app_493.clas.abap', import.meta.url); + +// The order of the WHEN branches follows the order the design's +// aggregations were filled in (collectEvents walks them as stored); a read +// class stores them in the order the view writes them, the catalogue's. +// The same view, the same class - up to that order, so the original is +// brought to it before the two are compared +function canonical(design) { + const d = clone(design); + walk(d.root, (n) => { + const sorted = {}; + for (const a of aggNames(n)) if (n.aggs[a]) sorted[a] = n.aggs[a]; + n.aggs = sorted; + return true; + }); + return d; +} + +// the two views as the runtime sees them: the preview's handler and the +// framework's for the same event +const events = (xml) => xml.replace(/"\.eB\(\)"/g, '"EVENT"').replace(/"\.onEvent\('[A-Z0-9_]+'\)"/g, '"EVENT"'); +const flat = (xml) => xml.replace(/>\s+<'); + +for (const [id, design] of [...allDesigns(), ...allDesigns702()]) { + test(`${id}: generate -> import -> generate is byte-identical`, () => { + const src = generateClass(design); + const back = fromClass(src, createDesign()); + const r = classReport(); + assert.deepEqual(r.dropped, [], 'every attribute of the chain came through'); + assert.deepEqual(r.unresolved, [], 'every binding found its attribute'); + assert.deepEqual(r.skipped, [], 'every public attribute has an editor type'); + assert.deepEqual(r.seeds, [], 'every seed was read'); + assert.equal(back.className, design.className); + assert.deepEqual(validate(back).filter((i) => i.level === 'error'), []); + // the export of an imported class patches the class it came from + assert.equal(generateClassSource(back), src, 'patched'); + assert.deepEqual(classSource(back).notes, []); + // and the design alone, generated from nothing, is the same class + delete back.abap; + assert.equal(generateClass(back), generateClass(canonical(design)), 'regenerated'); + }); + + test(`${id}: the imported design builds the view the linter reconstructs from the class`, () => { + const src = generateClass(design); + const back = fromClass(src, createDesign()); + const p = prepareAbap(src); + assert.equal(p.unplacedTokens, 0); + assert.equal(events(flat(toXml(back))), events(p.docs[0])); + }); +} + +test('the attributes and their seeds come out of the class as the data panel holds them', () => { + const d = allDesigns().find(([id]) => id === 'kitchen-sink-1')[1]; + const back = fromClass(generateClass(d), createDesign()); + const by = Object.fromEntries(back.attributes.map((a) => [a.name, a])); + assert.equal(by.text.value, 'Hello `world` {not a binding}'); + assert.equal(by.count.value, '42'); + assert.equal(by.amount.value, '1234.50', 'a decimal keeps its text'); + assert.equal(by.flag.value, true); + assert.equal(by.day.value, '2026-10-14'); + assert.equal(by.clock.value, '12:30:00'); + assert.deepEqual(by.t_rows.fields.map((f) => `${f.name}:${f.type}`), ['NAME:string', 'QTY:int', 'PRICE:decimal', 'DONE:bool', 'DUE:date', 'AT:time']); + assert.equal(by.t_rows.rows.length, 3); + assert.deepEqual(by.t_rows.rows[1], { NAME: 'Beta', QTY: '2', PRICE: '19.99', DONE: false, DUE: '2026-02-01', AT: '09:30:00' }); + assert.deepEqual(by.t_blank.rows, [{}, {}], 'rows that set nothing are rows'); + assert.equal(by.t_tree.fields[1].type, 'table', 'a table field is a table'); + assert.deepEqual(by.t_tree.rows[0].NODES, [{ TEXT: 'Child 1', DONE: true }, { TEXT: 'Child 2', DONE: false }]); + assert.deepEqual(by.t_tree.rows[1].NODES, undefined, 'a row without nodes has none'); +}); + +test('line breaks, tabs and long texts come back as they were written, in both releases', () => { + const [, d] = allDesigns().find(([id]) => id === 'texts'); + for (const release of ['', '702']) { + const source = generateClass(Object.assign(clone(d), { release })); + const back = fromClass(source, createDesign()); + const by = Object.fromEntries(back.attributes.map((a) => [a.name, a])); + const want = Object.fromEntries(d.attributes.map((a) => [a.name, a])); + assert.equal(by.note.value, want.note.value, `a string template / char constants (${release || '7.40'})`); + assert.equal(by.long_text.value, want.long_text.value, `pieces joined with && (${release || '7.40'})`); + assert.equal(by.t_notes.rows[0].BODY, 'first\nsecond'); + assert.equal(by.t_notes.rows[0].LINES[1].TEXT, want.long_text.value); + assert.deepEqual(classReport().seeds || [], [], 'every seed was read'); + } +}); + +test('a string template with an expression in it is not a literal', () => { + const d = createDesign(); + d.className = 'ZCL_LE_TEMPLATE'; + d.attributes = [{ name: 'greeting', type: 'string', value: '' }]; + const source = generateClass(d).replace("greeting = ``.", 'greeting = |Hello { sy-uname }|.'); + const back = fromClass(source, createDesign()); + assert.equal(back.attributes.find((a) => a.name === 'greeting').value, ''); +}); + +test('a real sample imports: the view, the controls the palette does not know, and a report of what it could not take', () => { + const src = readFileSync(SAMPLE, 'utf8'); + const d = fromClass(src, createDesign()); + const r = classReport(); + assert.equal(d.className, 'Z2UI5_CL_SMP_APP_493'); + assert.deepEqual(d.attributes, []); + const shell = d.root.aggs.content[0]; + assert.equal(shell.type, 'Shell'); + assert.deepEqual(r.unknown, ['Shell']); + const page = shell.aggs[''][0]; + assert.equal(page.type, 'Page'); + assert.equal(page.props.title.v, 'abap2UI5 - Basics I - Hello World, the Smallest App'); + const [strip, title] = page.aggs.content; + assert.equal(strip.type, 'MessageStrip'); + assert.ok(strip.props.text.v.startsWith('The whole app is what you see below') && strip.props.text.v.endsWith('your own app.'), 'the && pieces are one text'); + assert.deepEqual(strip.props.showIcon, { m: 's', v: 'true' }, 'b = abap_true'); + assert.equal(title.props.level.v, 'H2'); + // what no field of the editor holds is kept as ABAP and reported, by control and attribute + assert.deepEqual(r.kept, [ + { control: 'Page', attr: 'showNavButton', abap: 'client->check_app_prev_stack( )' }, + { control: 'Page', attr: 'navButtonPress', abap: 'client->_event_nav_app_leave( )' }, + ]); + assert.deepEqual(r.dropped, []); + assert.deepEqual(d.abap.raw[page.id].map((a) => `${a.name} ${a.key}`), ['showNavButton b', 'navButtonPress v']); + // the linter agrees on everything it renders + const p = prepareAbap(src); + const mine = flat(toXml(d)).replace(/]*>/, ''); + const theirs = p.docs[0].replace(/]*>/, '').replace(/ showNavButton="[^"]*"| navButtonPress="[^"]*"/g, ''); + assert.equal(mine, theirs); +}); + +test('the export of the sample keeps the class and writes the chain over the three statements it had', () => { + const src = readFileSync(SAMPLE, 'utf8'); + const d = fromClass(src, createDesign()); + const out = generateClassSource(d); + // the header comment, the definition, the IF around the view, the display call and the method end are untouched + for (const kept of ['" @keywords hello world', 'CLASS z2ui5_cl_smp_app_493 DEFINITION PUBLIC.', ' IF client->check_on_navigated( ).', ' client->view_display( view->stringify( ) ).', ' ENDIF.\n\n ENDMETHOD.\nENDCLASS.\n']) { + assert.ok(out.includes(kept), `kept: ${kept}`); + } + assert.ok(!out.includes('DATA(page)'), 'the split chain is one chain now'); + assert.ok(out.includes(' DATA(view) = z2ui5_cl_ui5_view_builder=>factory(\n )->ele( n = `View` ns = `mvc`'), 'in the column the old chain started in'); + assert.ok(out.includes(" )->tag( `Title`\n )->a( n = `text` v = `Hello World`")); + // the attributes the editor has no field for are back, as they were written + assert.ok(out.includes(' )->a( n = `showNavButton` b = client->check_app_prev_stack( )\n )->a( n = `navButtonPress` v = client->_event_nav_app_leave( )\n'), out); + assert.ok(out.split('\n').every((l) => l.length <= 255)); + // the lines of the chain are linked to their controls, the rest to none + const { nodeIds, source } = classSource(d); + const lines = source.split('\n'); + assert.equal(nodeIds.length, lines.length); + assert.ok(nodeIds[lines.findIndex((l) => l.includes('->tag( `Title`'))]); + assert.equal(nodeIds[lines.findIndex((l) => l.startsWith('CLASS '))], null); + // the linter reads the regenerated class to the same view, up to the two kept attributes + const theirs = events(prepareAbap(out).docs[0]).replace(/]*>/, '').replace(/ showNavButton="[^"]*"| navButtonPress="[^"]*"/g, ''); + assert.equal(events(flat(toXml(d))).replace(/]*>/, ''), theirs); + // and an import of the export is the same class again + assert.equal(generateClassSource(fromClass(out, createDesign())), out); +}); + +test('an attribute and an event added in the editor are declared and handled in the imported class', () => { + const form = allDesigns().find(([id]) => id === 'form')[1]; + const src = generateClass(form); + const d = fromClass(src, createDesign()); + d.attributes.push({ name: 'note', type: 'string', value: '' }); + d.attributes.push({ name: 't_items', type: 'table', fields: [{ name: 'KEY', type: 'string' }, { name: 'QTY', type: 'int' }], rows: [] }); + const page = d.root.aggs.content[0]; + const input = createNode('Input'); + input.props.value = { m: 'b', v: 'note' }; + input.events.submit = 'ADD'; + insertNode(d, input, page.id); + const out = generateClassSource(d); + assert.ok(out.includes(' DATA is_active TYPE abap_bool.\n\n TYPES:\n BEGIN OF ty_s_items,\n key TYPE string,\n qty TYPE i,\n END OF ty_s_items.\n DATA t_items TYPE STANDARD TABLE OF ty_s_items WITH EMPTY KEY.\n DATA note TYPE string.\n\n PROTECTED SECTION.'), out); + assert.ok(out.includes(" WHEN `CANCEL`.\n client->message_toast_display( `CANCEL - implement me in on_event` ).\n WHEN `ADD`.\n client->message_toast_display( `ADD - implement me in on_event` ).\n ENDCASE."), out); + assert.ok(out.includes(')->a( n = `value` v = client->_bind( note )')); + assert.deepEqual(classSource(d).notes, []); + // the chain lines still point at their controls after the lines above moved + const { nodeIds, source } = classSource(d); + const lines = source.split('\n'); + assert.equal(nodeIds[lines.findIndex((l) => l.includes('->tag( `Switch`'))], page.aggs.content[0].aggs.content[7].id); + // the class is still what the linter reads as this view + assert.equal(events(flat(toXml(d))), events(prepareAbap(out).docs[0])); + // the abapGit files carry the patched class + assert.equal(classFiles(d)['src/zcl_my_form.clas.abap'], out); +}); + +test('an event the class cannot take a branch for is said, not lost', () => { + const src = readFileSync(SAMPLE, 'utf8'); + const d = fromClass(src, createDesign()); + const page = d.root.aggs.content[0].aggs[''][0]; + const b = createNode('Button', { props: { text: 'Go' } }); + b.events.press = 'GO'; + insertNode(d, b, page.id); + const { source, notes } = classSource(d); + assert.ok(source.includes('v = client->_event( `GO` )')); + assert.equal(notes.length, 1); + assert.match(notes[0], /GO/); + assert.ok(!source.includes('WHEN `GO`')); +}); + +test('a chain in a variable of another name keeps that name', () => { + const src = generateClass(allDesigns()[0][1]).replace(/DATA\(view\)/, 'DATA(page)').replace(/view->stringify/, 'page->stringify'); + const d = fromClass(src, createDesign()); + assert.equal(generateClassSource(d), src); +}); + +test('what cannot be read is refused with a reason', () => { + assert.throws(() => fromClass('CLASS zcl_x DEFINITION PUBLIC.\n PUBLIC SECTION.\n INTERFACES z2ui5_if_app.\nENDCLASS.\nCLASS zcl_x IMPLEMENTATION.\nENDCLASS.\n', createDesign()), /Nothing here builds a view/); + const two = generateClass(allDesigns()[0][1]); + assert.throws(() => fromClass(two + two, createDesign()), /more than one view/); + assert.throws(() => fromClass(two.replace('tag( `Page`', 'tag( lv_name'), createDesign()), /not a literal/); + assert.throws(() => fromClass(two.replace("ele( n = `View` ns = `mvc`", "ele( n = `FragmentDefinition` ns = `core`"), createDesign()), /not an mvc:View/); + const chain = readViewChain('METHOD x. DATA(v) = z2ui5_cl_ui5_view_builder=>factory( )->ele( `View` )->foo( ). ENDMETHOD.'); + assert.equal(chain.ok, false); + assert.match(chain.why, /foo/); +}); + +test('the public section: the types the editor has, the rest reported, DATA: chains and VALUE', () => { + const pub = readPublicSection(`CLASS zcl_x DEFINITION PUBLIC. + PUBLIC SECTION. + INTERFACES z2ui5_if_app. + TYPES: BEGIN OF ty_row, + id TYPE string, + qty TYPE i, + price TYPE p LENGTH 16 DECIMALS 2, + done TYPE abap_bool, + END OF ty_row. + TYPES ty_t_rows TYPE STANDARD TABLE OF ty_row WITH DEFAULT KEY. + TYPES: BEGIN OF ty_deep, name TYPE string, obj TYPE REF TO object, END OF ty_deep. + DATA: mv_name TYPE string VALUE 'Jane', " a comment with a . in it + mv_count TYPE i, + mv_when TYPE d. + DATA mt_rows TYPE ty_t_rows. + DATA mt_rows2 TYPE TABLE OF ty_row. + DATA mt_deep TYPE STANDARD TABLE OF ty_deep WITH EMPTY KEY. + DATA mv_bukrs TYPE bukrs. + DATA mo_ref TYPE REF TO zcl_other. + DATA ms_row TYPE ty_row. + CONSTANTS c TYPE string VALUE 'x'. + METHODS foo. + PROTECTED SECTION. +ENDCLASS.`); + assert.deepEqual(pub.attributes.map((a) => [a.name, a.type, a.value]), [['mv_name', 'string', 'Jane'], ['mv_count', 'int', ''], ['mv_when', 'date', ''], ['mt_rows', 'table', undefined], ['mt_rows2', 'table', undefined]]); + assert.deepEqual(pub.attributes[3].fields, [{ name: 'ID', type: 'string' }, { name: 'QTY', type: 'int' }, { name: 'PRICE', type: 'decimal' }, { name: 'DONE', type: 'bool' }]); + assert.deepEqual(pub.skipped.map((s) => s.name), ['mt_deep', 'mv_bukrs', 'mo_ref', 'ms_row']); + assert.deepEqual(pub.declared, ['mv_name', 'mv_count', 'mv_when', 'mt_rows', 'mt_rows2', 'mt_deep', 'mv_bukrs', 'mo_ref', 'ms_row']); +}); + +test('seeds: model_init first, literals only, the rest said', () => { + const attrs = [{ name: 'name', type: 'string', value: '' }, { name: 'n', type: 'int', value: '' }, { name: 't_rows', type: 'table', fields: [{ name: 'A', type: 'string' }, { name: 'B', type: 'bool' }], rows: [] }]; + const notes = readSeeds(`CLASS zcl_x IMPLEMENTATION. + METHOD z2ui5_if_app~main. + name = \`not this one\`. + ENDMETHOD. + METHOD model_init. + me->name = 'it''s'. + n = lv_other. + t_rows = VALUE #( ( a = \`x\` b = abap_true ) ( a = \`y\` ) ( a = lv_z ) ). + ENDMETHOD. +ENDCLASS.`, attrs); + assert.equal(attrs[0].value, "it's"); + assert.equal(attrs[1].value, ''); + assert.deepEqual(attrs[2].rows, [{ A: 'x', B: true }, { A: 'y' }, {}]); + assert.deepEqual(notes, ['n: its value is not a literal, the sample value stays empty', 't_rows-A: not a literal, left empty']); +}); + +test('where a class may come from', () => { + const here = 'https://abap2ui5.github.io/layout-editor/index.html'; + assert.equal(sourceUrl('https://raw.githubusercontent.com/abap2UI5/samples/main/src/z2ui5_cl_smp_app_493.clas.abap', here), 'https://raw.githubusercontent.com/abap2UI5/samples/main/src/z2ui5_cl_smp_app_493.clas.abap'); + assert.equal(sourceUrl('https://github.com/abap2UI5/samples/blob/main/src/z2ui5_cl_smp_app_493.clas.abap', here), 'https://raw.githubusercontent.com/abap2UI5/samples/main/src/z2ui5_cl_smp_app_493.clas.abap'); + assert.equal(sourceUrl('/test/fixtures/x.clas.abap', here), 'https://abap2ui5.github.io/test/fixtures/x.clas.abap'); + assert.throws(() => sourceUrl('https://example.com/x.clas.abap', here), /not from example.com/); + assert.throws(() => sourceUrl('https://user:pw@raw.githubusercontent.com/a/b/c.clas.abap', here), /user name/); + assert.equal(repoClassUrl('ZCL_MY_APP', 'abap2UI5/samples'), 'https://raw.githubusercontent.com/abap2UI5/samples/main/src/zcl_my_app.clas.abap'); + assert.equal(repoClassUrl('/abc/cl_app', 'o/r', 'dev', ''), 'https://raw.githubusercontent.com/o/r/dev/%23abc%23cl_app.clas.abap'.replace(/%23/g, '#')); + assert.throws(() => repoClassUrl('ZCL_X', 'not a repo'), /owner\/repo/); +}); + +test('raw attributes: kept on their control, replaced by what the editor sets, gone with the control', () => { + const src = generateClass(allDesigns().find(([id]) => id === 'form')[1]) + .replace(')->tag( `Input`\n', ')->tag( `Input`\n )->a( n = `id` v = `nameInput`\n )->a( n = `core:require` v = `{ Util: \'z/util\' }`\n )->a( n = `valueState` v = COND #( WHEN name IS INITIAL\n THEN `Error` ELSE `None` )\n') + .replace(')->a( n = `height` v = `100%`', ')->a( n = `height` v = `100%`\n )->a( n = `controllerName` v = lv_controller'); + const d = fromClass(src, createDesign()); + const r = classReport(); + assert.deepEqual(r.kept.map((k) => `${k.control}.${k.attr} = ${k.abap}`), [ + 'Input.id = `nameInput`', + "Input.core:require = `{ Util: 'z/util' }`", + 'Input.valueState = COND #( WHEN name IS INITIAL THEN `Error` ELSE `None` )', + ], 'a multi-line operand is one line, its literal untouched'); + assert.deepEqual(r.dropped, [{ control: 'mvc:View', attr: 'controllerName', abap: 'lv_controller' }]); + const out = generateClassSource(d); + assert.ok(out.includes(')->a( n = `valueState` v = COND #( WHEN name IS INITIAL THEN `Error` ELSE `None` )'), out); + assert.ok(out.includes(')->a( n = `core:require` v = `{ Util: \'z/util\' }`')); + assert.ok(!out.includes('controllerName'), 'the View is the generator\'s'); + // the editor's own value wins over the kept one + const form = d.root.aggs.content[0].aggs.content[0]; + const input = form.aggs.content[1]; + input.props.valueState = { m: 's', v: 'Warning' }; + const set = generateClassSource(d); + assert.ok(set.includes('v = `Warning`') && !set.includes('COND #(')); + // a deleted control takes its raw attributes along + form.aggs.content.splice(1, 1); + assert.ok(!generateClassSource(d).includes('nameInput')); +}); diff --git a/test/e2e/designer.spec.mjs b/test/e2e/designer.spec.mjs index 1c1ff92..9e8339e 100644 --- a/test/e2e/designer.spec.mjs +++ b/test/e2e/designer.spec.mjs @@ -316,3 +316,94 @@ test('a view UI5 cannot render says why on the canvas, and the next edit renders await expect(page.locator('#previewStatus')).toBeHidden(); await expect(preview(page).getByText('Customer data')).toBeVisible(); }); + +test('an abap2UI5 class opens as a design and exports as the same class', async ({ designer: page }) => { + const original = await abapOf(page); + await page.locator('#btnFile').click(); + await page.locator('#btnOpenClass').click(); + await page.locator('#dlg textarea').fill(original); + await page.locator('#dlg').getByRole('button', { name: 'Open', exact: true }).click(); + await expect(page.locator('#dlg')).toContainText('Opened ZCL_MY_FORM'); + await page.locator('#dlg').getByRole('button', { name: 'OK' }).click(); + await expect(preview(page).getByText('Customer data')).toBeVisible(); + await expect(page.locator('#codeInfo')).toContainText('imported class'); + const back = await page.evaluate(() => window.designer.generateClassSource(window.designer.state.design)); + expect(back).toBe(original); + await waitRendered(page); +}); + +test('?src= opens a class from a URL, with a report of what the editor keeps as ABAP', async ({ page, ui5 }) => { + await page.addInitScript(() => { try { localStorage.clear(); } catch { /* fresh context */ } }); + await page.goto(`/index.html?ui5=${encodeURIComponent(ui5)}&src=/test/fixtures/z2ui5_cl_smp_app_493.clas.abap`); + await expect(page.locator('#dlg')).toContainText('Opened Z2UI5_CL_SMP_APP_493'); + await expect(page.locator('#dlg')).toContainText('Kept as ABAP'); + await expect(page.locator('#dlg')).toContainText('Page.navButtonPress = client->_event_nav_app_leave( )'); + await page.locator('#dlg').getByRole('button', { name: 'OK' }).click(); + await waitRendered(page); + await expect(preview(page).getByRole('heading', { name: 'Hello World', exact: true })).toBeVisible(); + expect(page.url()).not.toContain('src='); + const abap = await page.evaluate(() => window.designer.generateClassSource(window.designer.state.design)); + expect(abap).toContain('" @keywords hello world'); + expect(abap).toContain(')->a( n = `navButtonPress` v = client->_event_nav_app_leave( )'); + expect(abap).toContain(' client->view_display( view->stringify( ) ).'); +}); + +test('Open in Playground opens the playground with the class in the link', async ({ designer: page }) => { + const original = await page.evaluate(() => window.designer.generateClassSource(window.designer.state.design)); + // the playground itself is not part of this test: its page is answered here + await page.context().route('https://abap2ui5.github.io/playground/**', (route) => route.fulfill({ body: 'playground', contentType: 'text/html' })); + await page.locator('#btnExport').click(); + const [popup] = await Promise.all([page.waitForEvent('popup'), page.locator('#btnPlayground').click()]); + await popup.waitForLoadState(); + const url = new URL(popup.url()); + expect(`${url.origin}${url.pathname}`).toBe('https://abap2ui5.github.io/playground/'); + const files = await popup.evaluate(async (fragment) => { + const bytes = Uint8Array.from(atob(fragment.slice(1).replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)); + const text = await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).text(); + return JSON.parse(text); + }, url.hash.slice(1)); + expect(files).toEqual([{ name: 'zcl_my_form.clas.abap', source: original }]); +}); + +test('the class is written for ABAP 7.02 when chosen, and saved with the design', async ({ designer: page }) => { + await page.locator('#abapRelease').selectOption('702'); + await expect(page.locator('#code')).toContainText('DATA view TYPE REF TO z2ui5_cl_ui5_view_builder.'); + await expect(page.locator('#code')).toContainText('IF client->check_on_init( ) = abap_true.'); + expect(await abapOf(page)).not.toMatch(/DATA\(|VALUE #\(/); + expect(await page.evaluate(() => window.designer.state.design.release)).toBe('702'); + await page.keyboard.press('Control+z'); + await expect(page.locator('#abapRelease')).toHaveValue(''); + await expect(page.locator('#code')).toContainText('DATA(view) = z2ui5_cl_ui5_view_builder=>factory('); +}); + +test('the canvas boots UI5 from a URL of your own', async ({ page }) => { + // the bootstrap the preview writes - no UI5 is loaded, from anywhere + await page.route((url) => url.pathname.endsWith('/sap-ui-core.js'), (route) => route.abort()); + const src = async (ui5) => { + await page.goto(`/preview.html?ui5=${encodeURIComponent(ui5)}`, { waitUntil: 'domcontentloaded' }); + return page.locator('#sap-ui-bootstrap').getAttribute('src'); + }; + // a host of its own is refused when the page is opened by a link - + // its scripts would run on this origin; the editor's field below may + expect(await src('https://ui5.example.com/1.71.80/')).toBe('https://sdk.openui5.org/resources/sap-ui-core.js'); + expect(await src('http://localhost:9999/ui5/')).toBe('http://localhost:9999/ui5/resources/sap-ui-core.js'); + expect(await src('https://ui5.sap.com/1.71.80/')).toBe('https://ui5.sap.com/1.71.80/resources/sap-ui-core.js'); + expect(await src('/ui5/mine/resources')).toBe('/ui5/mine/resources/sap-ui-core.js'); + expect(await src('./ui5/resources/sap-ui-core.js')).toBe('./ui5/resources/sap-ui-core.js'); + expect(await src('1.71.80')).toBe('https://sdk.openui5.org/1.71.80/resources/sap-ui-core.js'); + expect(await src('/">')).toBe('https://sdk.openui5.org/%2F%22%3E%3Cb%3E/resources/sap-ui-core.js'); + // the editor's field takes the URL and hands it to the canvas + await page.goto('/index.html'); + await page.locator('#ui5version').fill('https://ui5.example.com/1.71.80'); + await page.locator('#ui5version').dispatchEvent('change'); + await expect(page.locator('#ui5version')).not.toHaveClass(/invalid/); + await expect(page.locator('#preview')).toHaveAttribute('src', /ui5=https%3A%2F%2Fui5\.example\.com%2F1\.71\.80/); + // framed by the editor, the canvas takes the typed host + await expect(page.frameLocator('#preview').locator('#sap-ui-bootstrap')).toHaveAttribute('src', 'https://ui5.example.com/1.71.80/resources/sap-ui-core.js'); + // the same host in a link to the editor is not taken, and says so - + // in a browser that has not been told it in the field before + await page.evaluate(() => { try { localStorage.clear(); } catch { /* fresh */ } }); + await page.goto(`/index.html?ui5=${encodeURIComponent('https://ui5.example.com/1.71.80')}`); + await expect(page.locator('body')).toContainText('not loaded from a link'); + await expect(page.locator('#preview')).not.toHaveAttribute('src', /ui5\.example\.com/); +}); diff --git a/test/fixtures.mjs b/test/fixtures.mjs index 5345d63..93f323b 100644 --- a/test/fixtures.mjs +++ b/test/fixtures.mjs @@ -156,7 +156,69 @@ export function kitchenSink(half) { return d; } +// Designs that came out of a class: a starter's class read back in and +// edited the way a user would - an attribute and an input with an event +// added - so its export is the patched class, not a generated one +export function importedDesigns(fromClass, generateClass) { + const form = STARTERS.find((s) => s.id === 'form').make(); + form.className = 'ZCL_LE_IMPORTED_FORM'; + const d = fromClass(generateClass(form), createDesign()); + d.attributes.push({ name: 'note', type: 'string', value: '' }); + const page = d.root.aggs.content[0]; + const input = createNode('Input'); + input.props.value = { m: 'b', v: 'note' }; + input.events.submit = 'ADD'; + insertNode(d, input, page.id); + return [['imported-form', d]]; +} + +// texts the two releases write differently: line breaks, tabs and texts +// longer than a line - a string template on 7.40, literal pieces around +// cl_abap_char_utilities on 7.02 - in an attribute and in the rows of a +// table and of its table field, a long text in the view +export function texts() { + const long = 'A text longer than one line of ABAP takes, so the class writes it in pieces. '.repeat(5).trim(); + const d = createDesign(); + d.className = 'ZCL_DSG_TEXTS'; + d.description = 'line breaks, tabs and long texts'; + d.attributes = [ + { name: 'note', type: 'string', value: 'Line one\nLine two\tafter a tab\r\nafter CR LF `quoted` {braces} |bars|' }, + { name: 'long_text', type: 'string', value: long }, + { + name: 't_notes', type: 'table', + fields: [{ name: 'TITLE', type: 'string' }, { name: 'BODY', type: 'string' }, { name: 'LINES', type: 'table', fields: [{ name: 'TEXT', type: 'string' }] }], + rows: [ + { TITLE: 'Two lines', BODY: 'first\nsecond', LINES: [{ TEXT: 'a\tb' }, { TEXT: long }] }, + { TITLE: 'Long', BODY: long, LINES: [] }, + { TITLE: 'Plain', BODY: '' }, + ], + }, + ]; + const page = d.root.aggs.content[0]; + page.props.title = { m: 's', v: 'Texts' }; + page.aggs.content = []; + // a line break in a view attribute stays out: abap2UI5 sends it as , + // the canvas's XML (src/xml.js) as it is - the unit tests hold the chain + insertNode(d, createNode('Text', { props: { text: `{braces} and ${long}` } }), page.id); + const area = createNode('TextArea', { props: { rows: '4' } }); + area.props.value = { m: 'b', v: 'note' }; + insertNode(d, area, page.id); + const text = createNode('Text', { props: {} }); + text.props.text = { m: 'b', v: 'long_text' }; + insertNode(d, text, page.id); + const list = createNode('List'); + insertNode(d, list, page.id); + bindListToTable(d, list.id, 'items', 't_notes'); + return d; +} + export function allDesigns() { - return [...STARTERS.map((s) => [s.id, s.make()]), ['kitchen-sink-1', kitchenSink(1)], ['kitchen-sink-2', kitchenSink(2)]]; + return [...STARTERS.map((s) => [s.id, s.make()]), ['kitchen-sink-1', kitchenSink(1)], ['kitchen-sink-2', kitchenSink(2)], ['texts', texts()]]; +} + +// the same designs as 7.02 classes, under a class name of their own so +// both releases can stand next to each other in one check +export function allDesigns702() { + return allDesigns().map(([id, d]) => [`${id}-702`, Object.assign(d, { className: `${d.className}_702`, release: '702' })]); } diff --git a/test/fixtures/z2ui5_cl_smp_app_493.clas.abap b/test/fixtures/z2ui5_cl_smp_app_493.clas.abap new file mode 100644 index 0000000..5ae082c --- /dev/null +++ b/test/fixtures/z2ui5_cl_smp_app_493.clas.abap @@ -0,0 +1,51 @@ +" @keywords hello world smallest first app minimal start here template +" @summary The smallest app that runs: one class, one view_display( ), a Page with a title - the shape every other sample starts from. +" @docs https://abap2ui5.github.io/docs/get_started/hello_world https://abap2ui5.github.io/docs/cookbook/view/definition https://abap2ui5.github.io/docs/cookbook/expert_more/snippets https://abap2ui5.github.io/docs/tutorials/step-1 +CLASS z2ui5_cl_smp_app_493 DEFINITION PUBLIC. + + PUBLIC SECTION. + INTERFACES z2ui5_if_app. + + PROTECTED SECTION. + PRIVATE SECTION. +ENDCLASS. + + +CLASS z2ui5_cl_smp_app_493 IMPLEMENTATION. + + METHOD z2ui5_if_app~main. + + IF client->check_on_navigated( ). + + DATA(view) = z2ui5_cl_ui5_view_builder=>factory( + )->ele( n = `View` ns = `mvc` + )->a( n = `displayBlock` v = `true` + )->a( n = `height` v = `100%` + )->a( n = `xmlns` v = `sap.m` + )->a( n = `xmlns:mvc` v = `sap.ui.core.mvc` ). + DATA(page) = view->ele( `Shell` + )->ele( `Page` + )->a( n = `title` v = `abap2UI5 - Basics I - Hello World, the Smallest App` + )->a( n = `showNavButton` b = client->check_app_prev_stack( ) + )->a( n = `navButtonPress` v = client->_event_nav_app_leave( ) ). + + page->tag( `MessageStrip` + )->a( n = `text` v = `The whole app is what you see below: a class implementing z2ui5_if_app, ` && + `one main( ) method, a view built as XML and handed to client->view_display( ). ` && + `abap2UI5 calls main( ) on every roundtrip - here only the display matters, ` && + `which is what check_on_navigated( ) asks: true on the first start and whenever ` && + `the app is shown again. Copy this class as the starting point for your own app.` + )->a( n = `type` v = `Information` + )->a( n = `showIcon` b = abap_true + )->a( n = `class` v = `sapUiSmallMargin` ). + + page->tag( `Title` + )->a( n = `text` v = `Hello World` + )->a( n = `class` v = `sapUiSmallMargin` + )->a( n = `level` v = `H2` ). + client->view_display( view->stringify( ) ). + + ENDIF. + + ENDMETHOD. +ENDCLASS. diff --git a/test/generator.test.mjs b/test/generator.test.mjs index 7024145..fdd85f3 100644 --- a/test/generator.test.mjs +++ b/test/generator.test.mjs @@ -5,8 +5,10 @@ // does the last part for real, on the transpiled abap2UI5. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { allDesigns, kitchenSink } from './fixtures.mjs'; -import { generateClass, generateClassXml, abapGitFiles, viewChain, abapString } from '../src/abap.js'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { allDesigns, allDesigns702, kitchenSink } from './fixtures.mjs'; +import { generateClass, generateClassXml, abapGitFiles, viewChain, abapString, releaseOf } from '../src/abap.js'; import { toXml, fromXml, escapeBindingLiteral } from '../src/xml.js'; import { validate, clone, walk } from '../src/model.js'; import { zip, crc32 } from '../src/zip.js'; @@ -14,7 +16,7 @@ import { builderXml } from './builder.mjs'; const flat = (xml) => xml.replace(/>\s+<'); -for (const [id, design] of allDesigns()) { +for (const [id, design] of [...allDesigns(), ...allDesigns702()]) { test(`${id}: the design has no problems`, () => { assert.deepEqual(validate(design).filter((i) => i.level !== 'info'), []); }); @@ -37,7 +39,9 @@ for (const [id, design] of allDesigns()) { assert.match(src, new RegExp(`^CLASS ${cls} DEFINITION PUBLIC\\.\\n`)); assert.match(src, /INTERFACES z2ui5_if_app\./); assert.match(src, /DATA client TYPE REF TO z2ui5_if_client\./); - assert.match(src, /me->client = client\.\n {4}IF client->check_on_(init|navigated)\( \)\./); + assert.match(src, releaseOf(design) === '702' + ? /me->client = client\.\n {4}IF client->check_on_(init|navigated)\( \) = abap_true\./ + : /me->client = client\.\n {4}IF client->check_on_(init|navigated)\( \)\./); assert.match(src, /client->view_display\( view->stringify\( \) \)\./); const hasEvents = JSON.stringify(design.root).includes('"events":{"'); assert.equal(/METHOD on_event\./.test(src), hasEvents, 'on_event exactly when the view raises events'); @@ -80,6 +84,60 @@ for (const [id, design] of allDesigns()) { }); } +// What 7.02 does not have - the constructs abap2UI5's own downport +// (abaplint's `downport` rule at v702) rewrites. Each is a statement or an +// expression that would not compile there; the abaplint run below is the +// authority, this list is the readable half of it. +const NOT_IN_702 = [ + [/\bDATA\(/, 'inline declaration DATA( )'], + [/\bFIELD-SYMBOL\(/, 'inline FIELD-SYMBOL( )'], + [/\|[^|\n]*\|/, 'string template |...|'], + [/\b(VALUE|NEW|COND|SWITCH|CONV|REF|CORRESPONDING|EXACT|REDUCE|FILTER)\s+#?\w*\s*\(/, 'constructor expression'], + [/\bLINE_EXISTS\(/, 'line_exists( )'], + [/\w\[\s/, 'table expression itab[ ]'], + [/\bFOR\s+\w+\s+IN\b/, 'FOR iteration'], + [/\bWITH EMPTY KEY\b/, 'WITH EMPTY KEY'], + [/\b(IF|ELSEIF)\s+\w+->\w+\(\s*\)\.$/m, 'predicative method call'], + [/->\*/, 'dereference chain ->*'], +]; + +for (const [id, design] of allDesigns702()) { + test(`${id}: the class uses no construct 7.02 lacks`, () => { + const src = generateClass(design); + assert.match(src, /^ {4}DATA view TYPE REF TO z2ui5_cl_ui5_view_builder\.$/m); + assert.match(src, /^ {4}view = z2ui5_cl_ui5_view_builder=>factory\($/m); + for (const line of src.split('\n')) { + // a backtick literal may hold anything; what is outside it is ABAP + const code = line.replace(/`(?:[^`]|``)*`/g, '``'); + for (const [re, what] of NOT_IN_702) assert.ok(!re.test(code), `${what} in: ${line.trim()}`); + } + if (design.attributes.some((a) => a.type === 'table' && (a.rows || []).length)) { + assert.match(src, /^ {4}DATA \w+_row +TYPE ty_s_\w+\.$/m, 'a row variable per table'); + assert.match(src, /^ {4}APPEND \w+_row TO \w+\.$/m); + } + }); +} + +test('the 7.02 classes pass abaplint at v702 (abaplint-702.jsonc)', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const { writeClasses } = await import('../tools/gen-classes.mjs'); + await writeClasses(); + const bin = fileURLToPath(new URL('../node_modules/@abaplint/cli/abaplint', import.meta.url)); + let out; + try { + out = execFileSync('node', [bin, 'abaplint-702.jsonc', '--format', 'json'], { cwd: root, encoding: 'utf8' }); + } catch (err) { + out = `${err.stdout || ''}${err.stderr || ''}`; + assert.fail(`abaplint: ${out.slice(0, 1500)}`); + } + // the JSON report is the list of issues: none + const issues = JSON.parse(out.slice(out.indexOf('['))); + assert.deepEqual(issues.map((i) => `${i.file.filename}:${i.start.row} ${i.key} ${i.description}`), []); + const { readdirSync } = await import('node:fs'); + const written = readdirSync(`${root}.verify/abap`).filter((f) => f.endsWith('_702.clas.abap')); + assert.equal(written.length, allDesigns702().length, 'every 7.02 class was checked'); +}); + test('the .clas.xml is what abapGit writes', () => { const d = kitchenSink(); d.description = "Tom's & more"; @@ -103,16 +161,22 @@ test('a namespaced class gets abapGit file names', () => { test('literals', () => { assert.equal(abapString('a`b'), '`a``b`'); assert.equal(abapString('two\nlines {x} |y|'), '|two\\nlines \\{x\\} \\|y\\||'); + assert.equal(abapString('two\nlines {x} |y|', '702'), '`two` && cl_abap_char_utilities=>newline && `lines {x} |y|`'); + assert.equal(abapString('a\r\nb\tc', '702'), '`a` && cl_abap_char_utilities=>cr_lf && `b` && cl_abap_char_utilities=>horizontal_tab && `c`'); assert.equal(escapeBindingLiteral('a {b} \\c'), 'a \\{b\\} \\\\c'); }); test('a long text is split so no line passes 255 characters', () => { - const d = kitchenSink(); - const long = 'x'.repeat(700); - walk(d.root, (n) => { if (n.type === 'Title') n.props.text = { m: 's', v: long }; }); - const src = generateClass(d); - assert.ok(src.split('\n').every((l) => l.length <= 255)); - assert.equal(builderXml(viewChain(d).join('\n')), flat(toXml(d))); + for (const release of ['740', '702']) { + const d = kitchenSink(); + d.release = release; + const long = 'x'.repeat(700); + walk(d.root, (n) => { if (n.type === 'Title') n.props.text = { m: 's', v: `${long}\n${long}` }; }); + d.attributes.find((a) => a.name === 't_rows').rows[0].NAME = long; + const src = generateClass(d); + assert.ok(src.split('\n').every((l) => l.length <= 255), release); + assert.equal(builderXml(viewChain(d).join('\n')), flat(toXml(d)), release); + } }); test('the zip is a zip', () => { diff --git a/test/handoff.test.mjs b/test/handoff.test.mjs new file mode 100644 index 0000000..8238308 --- /dev/null +++ b/test/handoff.test.mjs @@ -0,0 +1,41 @@ +// The hand-off links (src/handoff.js) decode the way their readers decode +// them: the playground's share fragment as src/shell/share.mjs in +// abap2UI5/playground reads it (one version character "2", base64url, +// deflate-raw, a JSON array of { name, source }), the VS Code link as the +// abap2UI5 extension's URI handler reads it (?code= base64url deflate-raw, +// ?name= the file). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { allDesigns } from './fixtures.mjs'; +import { generateClass } from '../src/abap.js'; +import { playgroundLink, playgroundFragment, vscodeLink, PLAYGROUND_URL } from '../src/handoff.js'; + +const fromBase64Url = (text) => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)); +const inflate = async (bytes) => new TextDecoder().decode(await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).arrayBuffer()); + +const source = generateClass(allDesigns()[0][1]); + +test('the playground link carries the class as the playground reads a share link', async () => { + const href = await playgroundLink('zcl_my_app.clas.abap', source); + const url = new URL(href); + assert.equal(`${url.origin}${url.pathname}`, PLAYGROUND_URL); + assert.equal(url.search, ''); + const fragment = url.hash.slice(1); + assert.equal(fragment[0], '2', 'version 2: a list of files'); + assert.match(fragment.slice(1), /^[A-Za-z0-9_-]+$/, 'base64url, no padding'); + assert.deepEqual(JSON.parse(await inflate(fromBase64Url(fragment.slice(1)))), [{ name: 'zcl_my_app.clas.abap', source }]); +}); + +test('the playground base URL is configurable, its query is dropped', async () => { + const href = await playgroundLink('zcl_x.clas.abap', 'CLASS zcl_x DEFINITION.', 'http://127.0.0.1:5173/?src=x#old'); + assert.ok(href.startsWith('http://127.0.0.1:5173/#2')); + assert.equal(await playgroundFragment([{ name: 'zcl_x.clas.abap', source: 'CLASS zcl_x DEFINITION.' }]), new URL(href).hash.slice(1)); +}); + +test('the VS Code link carries the class for the extension\'s URI handler', async () => { + const href = await vscodeLink('zcl_my_app.clas.abap', source); + assert.ok(href.startsWith('vscode://abap2ui5.abap2ui5/open?')); + const params = new URL(href).searchParams; + assert.equal(params.get('name'), 'zcl_my_app.clas.abap'); + assert.equal(await inflate(fromBase64Url(params.get('code'))), source); +}); diff --git a/tools/gen-classes.mjs b/tools/gen-classes.mjs index d3856f6..924a0d4 100644 --- a/tools/gen-classes.mjs +++ b/tools/gen-classes.mjs @@ -1,18 +1,21 @@ // Writes the class of every test design (test/fixtures.mjs) to .verify/abap, -// for the abap2UI5 linter (npm run check:abap) and the runtime check -// (npm run verify:abap). +// for 7.40 and for 7.02 (*_702.clas.abap), for the abap2UI5 linter (npm run +// check:abap), abaplint's 7.02 check (npm run check:abap702) and the runtime +// check (npm run verify:abap) - and an imported, edited class, whose +// export is the patched original (src/clas.js). import { mkdir, rm, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { allDesigns } from '../test/fixtures.mjs'; +import { allDesigns, allDesigns702, importedDesigns } from '../test/fixtures.mjs'; import { generateClass, fileName } from '../src/abap.js'; +import { fromClass, generateClassSource } from '../src/clas.js'; export const WORK = fileURLToPath(new URL('../.verify', import.meta.url)); export async function writeClasses() { - const designs = allDesigns(); + const designs = [...allDesigns(), ...allDesigns702(), ...importedDesigns(fromClass, generateClass)]; await rm(WORK, { recursive: true, force: true }); await mkdir(`${WORK}/abap`, { recursive: true }); - for (const [, d] of designs) await writeFile(`${WORK}/abap/${fileName(d.className, 'clas.abap')}`, generateClass(d)); + for (const [, d] of designs) await writeFile(`${WORK}/abap/${fileName(d.className, 'clas.abap')}`, generateClassSource(d)); return designs; }